azeroth-shell 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +43 -10
- package/bin/azsh.mjs +15 -1
- package/package.json +7 -3
- package/src/cli/hdArt.mjs +1 -1
- package/src/cli/iconArt.mjs +11 -1
- package/src/cli/modelArt.mjs +7 -4
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/repl.mjs +16 -0
- package/src/cli/sixel.mjs +26 -14
- package/src/cli/text.mjs +16 -0
- package/src/cli/tui.mjs +40 -13
- package/src/cli/tuiAutopilot.mjs +17 -8
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBulk.mjs +83 -4
- package/src/cli/tuiCheck.mjs +328 -19
- package/src/cli/tuiCity.mjs +31 -31
- package/src/cli/tuiCompanionKit.mjs +26 -8
- package/src/cli/tuiCompanions.mjs +189 -29
- package/src/cli/tuiCreate.mjs +7 -0
- package/src/cli/tuiDungeon.mjs +113 -33
- package/src/cli/tuiFight.mjs +63 -21
- package/src/cli/tuiFightPanes.mjs +15 -6
- package/src/cli/tuiFloor.mjs +20 -8
- package/src/cli/tuiGear.mjs +9 -10
- package/src/cli/tuiLoot.mjs +5 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +6 -5
- package/src/cli/tuiPartyStage.mjs +36 -7
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +23 -23
- package/src/cli/tuiSaves.mjs +14 -0
- package/src/cli/tuiSearch.mjs +4 -5
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +72 -16
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +374 -85
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +88 -5
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +36 -36
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- package/src/domain/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/engine/combat/pickAction.ts +3494 -3487
- package/src/engine/combat/sessionChase.ts +12 -1
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/professions.ts +4 -0
- package/src/engine/game/squadQuestShare.ts +13 -1
- package/src/engine/game/types.ts +4 -0
package/src/cli/tuiViews.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// The tabs and dialogs of the windowed game. A view is `{ id, title, hint, rows(app), keys, detail }`; a row is
|
|
2
2
|
// `{ text, header?, act?, detail?, keys? }`. Views read the Game facade and DataStore; every change is a facade call.
|
|
3
|
-
import { bold, dim, cyan, yellow, green, red, gray, magenta, brightBlue, brightYellow, qualityPaint, QUALITY, questText, wrapLines, fit, secondsText, plural, vis } from './text.mjs';
|
|
3
|
+
import { bold, dim, cyan, yellow, green, red, gray, magenta, brightBlue, brightYellow, qualityPaint, QUALITY, questText, wrapLines, fit, secondsText, plural, vis, coinText } from './text.mjs';
|
|
4
4
|
import { ruleSentence, conditionsSentence, condSentence, PHASE_LABEL } from './rotationText.mjs';
|
|
5
5
|
import { startFight, startAutopilot } from './tuiFight.mjs';
|
|
6
6
|
import { className, raceName } from '../domain/core/ids.ts';
|
|
7
7
|
import { nativeDisplayId, wornDisplay } from '../domain/character/displayForm.ts';
|
|
8
|
-
import {
|
|
8
|
+
import { formDisplayId } from '../domain/character/forms.ts';
|
|
9
|
+
import { factionOf } from '../domain/character/races.ts';
|
|
10
|
+
import { SHAPESHIFT_FORM, CREATURE_TYPE } from '../data/schema.ts';
|
|
9
11
|
import { maxHealth, maxPower } from '../domain/character/character.ts';
|
|
10
12
|
import { effectiveStats, effectiveArmor, attackPower, powerType } from '../domain/character/characterStats.ts';
|
|
11
13
|
import { critChanceDisplay, dodgeChanceDisplay, hitChance, weaponSkillIdForHand, skillBonusFor } from '../domain/character/characterRatings.ts';
|
|
@@ -18,32 +20,36 @@ import { stackBoundToHolder } from '../domain/raid/partyVault.ts';
|
|
|
18
20
|
import { knows, learnGrantIds } from '../domain/character/spellbook.ts';
|
|
19
21
|
import { availablePoints, canSpend, canRefund, spentInTree } from '../domain/character/talents.ts';
|
|
20
22
|
import { offeredQuests, chainFollowUpOffer, grayLogQuests } from '../quests/quests.ts';
|
|
21
|
-
import { isRepeatableQuest, questRepeatsAvailable } from '../domain/quests/questlog.ts';
|
|
23
|
+
import { isRepeatableQuest, questRepeatsAvailable, isTalkToNpcQuest, isEventTalkQuest, eventCreditIsTheRemainingWork } from '../domain/quests/questlog.ts';
|
|
24
|
+
import { GARMENTS_ENTRY_QUEST, SPELL_LESSER_HEAL_R2, SPELL_FORTITUDE_R1 } from '../domain/scriptdev/npcsSpecial.ts';
|
|
22
25
|
import { questRewardChoices } from '../domain/quests/questRewardChoice.ts';
|
|
23
26
|
import { pushBuyback, buyBack as buyBackDomain } from '../domain/economy/trading.ts';
|
|
24
|
-
import { cityAccess, bankDialog, auctionDialog, mailDialog } from './tuiCity.mjs';
|
|
27
|
+
import { cityAccess, bankDialog, auctionDialog, mailDialog, tabStrip } from './tuiCity.mjs';
|
|
25
28
|
import { professionsView, profTrainerDialog, worldGatherRows, openSendGoods, reagentUses } from './tuiProfessions.mjs';
|
|
26
29
|
import { companionsView } from './tuiCompanions.mjs';
|
|
27
30
|
import { companionLevel, companionCharacter, companionSpellbook } from '../domain/bots/squad.ts';
|
|
28
31
|
import { squadJoinsPull } from './squad.mjs';
|
|
32
|
+
import { huntableCreatures, questTargets, isQuestMob, zoneWeaponTrainers } from '../world/world.ts';
|
|
33
|
+
import { canInteractWithCreature } from '../domain/creature/faction.ts';
|
|
29
34
|
import { barsDialog, playerBarsOwner } from './tuiBars.mjs';
|
|
30
35
|
import { petWorldRows, petDialog, hasPetClass } from './tuiPet.mjs';
|
|
31
36
|
import { portalRows } from './tuiDungeon.mjs';
|
|
32
37
|
import { conditionEditor, defaultConditionsForSpell, defaultItemRule } from './tuiRotationCond.mjs';
|
|
33
38
|
import { allyIndexOfSide } from '../engine/core/events.ts';
|
|
34
|
-
import { bulkDisenchantDialog, learnAllDialog } from './tuiBulk.mjs';
|
|
39
|
+
import { bulkDisenchantDialog, learnAllDialog, destroyManyDialog } from './tuiBulk.mjs';
|
|
35
40
|
import { gearExtraRows, durabilityText, repairOne, repairAll, slotRepairLines, enchantLines } from './tuiGear.mjs';
|
|
36
41
|
import { outfitterDialog } from './tuiOutfitter.mjs';
|
|
37
42
|
import { castWithPicker } from './tuiEnchantTarget.mjs';
|
|
38
43
|
import { settingsDialog } from './tuiSettings.mjs';
|
|
39
44
|
import { macrosDialog } from './tuiMacros.mjs';
|
|
40
|
-
import { pickPocket, pickPocketTag, journeyRows, afterDeparture, ridingRows, objectRows, meetingStoneQueueRows, outdoorPvpRows } from './tuiWorld.mjs';
|
|
45
|
+
import { hereRows, pickPocket, pickPocketTag, tame, tameTag, tameState, questUseItem, journeyRows, afterDeparture, ridingRows, objectRows, meetingStoneQueueRows, outdoorPvpRows } from './tuiWorld.mjs';
|
|
41
46
|
import { talentBuildsMenu, myTalentLink } from './tuiTalents.mjs';
|
|
42
47
|
import { simLabDialog } from './tuiSimLab.mjs';
|
|
48
|
+
import { withWorldMap } from './tuiMap.mjs';
|
|
43
49
|
import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
|
|
44
50
|
import { preflightCard } from './tuiAutopilot.mjs';
|
|
45
51
|
import { creatureArt } from './modelArt.mjs';
|
|
46
|
-
import { withIcon, iconsOn } from './iconArt.mjs';
|
|
52
|
+
import { withIcon, iconsOn, miniIcon } from './iconArt.mjs';
|
|
47
53
|
import { paperdollGrid, paperdollPlan } from './tuiPaperdoll.mjs';
|
|
48
54
|
import { talentGrid, talentPlan, talentTone, talentIcon } from './tuiTalentTree.mjs';
|
|
49
55
|
import { paperdollArt } from './paperdoll.mjs';
|
|
@@ -127,7 +133,7 @@ function itemLinesText(app, item, extra = []) {
|
|
|
127
133
|
}
|
|
128
134
|
}
|
|
129
135
|
if (item.description) out.push(yellow(`"${item.description}"`));
|
|
130
|
-
if (item.sellPrice) out.push(dim(`Sells for ${
|
|
136
|
+
if (item.sellPrice) out.push(dim(`Sells for ${coinText(item.sellPrice)}`));
|
|
131
137
|
out.push(...extra);
|
|
132
138
|
return out;
|
|
133
139
|
}
|
|
@@ -161,7 +167,9 @@ export function creatureLines(app, c) {
|
|
|
161
167
|
out.push(`${hp} health hits ${c.dmgMin}-${c.dmgMax} every ${(c.attackTimeMs / 1000).toFixed(1)} s`);
|
|
162
168
|
if (c.armor) out.push(`${c.armor} armor`);
|
|
163
169
|
if (c.xp) out.push(dim(`about ${c.xp} xp at your level`));
|
|
164
|
-
|
|
170
|
+
const tm = tameState(app, c);
|
|
171
|
+
if (tm === 'use') out.push(green(`t: use ${questUseItem(app).name} on it`));
|
|
172
|
+
else if (c.tameable) out.push(green({ tame: 'Tameable: t tames it', pet: `Tameable, once ${app.ch.pet?.name} is dismissed or stabled`, unlearned: 'Tameable once you learn Tame Beast' }[tm] ?? 'Tameable'));
|
|
165
173
|
const wanted = app.g.state.questlog.quests.flatMap((qs) => {
|
|
166
174
|
const q = app.ds.quest(qs.entry);
|
|
167
175
|
const k = q?.killTargets.find((t) => t.creatureEntry === c.entry);
|
|
@@ -213,13 +221,33 @@ export function objectiveLines(app, q, qs) {
|
|
|
213
221
|
export function rewardLines(app, q) {
|
|
214
222
|
const out = [];
|
|
215
223
|
if (q.rewXP) out.push(magenta(`${q.rewXP} experience`));
|
|
216
|
-
if (q.rewMoney) out.push(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
224
|
+
if (q.rewMoney) out.push(coinText(q.rewMoney));
|
|
225
|
+
const item = (it) => `${miniIcon(app.ds.item(it.item)?.icon)}${app.itemLabel(it.item)}${it.count > 1 ? ` x${it.count}` : ''}`;
|
|
226
|
+
for (const it of q.rewItems) out.push(item(it));
|
|
227
|
+
if (q.rewChoiceItems?.length) out.push('Choose one of:', ...q.rewChoiceItems.map((it) => ` ${item(it)}`));
|
|
228
|
+
for (const s of q.rewSpells ?? []) out.push(`${miniIcon(app.ds.spell(s)?.icon)}Learn ${app.spellName(s)}`);
|
|
220
229
|
return out;
|
|
221
230
|
}
|
|
222
231
|
|
|
232
|
+
/** The rewards on the quest page: each item a row with its icon and its tooltip, the money in coins. */
|
|
233
|
+
function rewardRows(app, q) {
|
|
234
|
+
const P = (t) => ({ text: t, header: true, plain: true });
|
|
235
|
+
const item = (it) => {
|
|
236
|
+
const def = app.ds.item(it.item);
|
|
237
|
+
return { text: `${app.itemLabel(it.item)}${it.count > 1 ? ` x${it.count}` : ''}`, icon: def?.icon, sub: dim(def?.typeName ?? ''), detail: () => (def ? itemLines(app, def) : []) };
|
|
238
|
+
};
|
|
239
|
+
const rows = [];
|
|
240
|
+
if (q.rewXP) rows.push(P(` ${magenta(`${q.rewXP} experience`)}`));
|
|
241
|
+
if (q.rewMoney) rows.push(P(` ${coinText(q.rewMoney)}`));
|
|
242
|
+
rows.push(...q.rewItems.map(item));
|
|
243
|
+
if (q.rewChoiceItems?.length) rows.push(P(' Choose one of:'), ...q.rewChoiceItems.map(item));
|
|
244
|
+
for (const s of q.rewSpells ?? []) {
|
|
245
|
+
const sp = app.ds.spell(s);
|
|
246
|
+
rows.push(sp ? { text: `Learn ${app.spellName(s)}`, icon: sp.icon, sub: dim('spell'), detail: () => spellLines(app, sp) } : P(` Learn ${app.spellName(s)}`));
|
|
247
|
+
}
|
|
248
|
+
return rows;
|
|
249
|
+
}
|
|
250
|
+
|
|
223
251
|
// ---- tabs ------------------------------------------------------------------------------------------
|
|
224
252
|
|
|
225
253
|
export function makeTabs(app) {
|
|
@@ -227,14 +255,14 @@ export function makeTabs(app) {
|
|
|
227
255
|
{ label: 'World', icon: 'inv_misc_map_01', view: worldView(app) },
|
|
228
256
|
{ label: 'Quests', icon: 'inv_misc_note_01', view: questsView(app) },
|
|
229
257
|
{ label: 'Bags', icon: 'inv_misc_bag_08', view: bagsView(app) },
|
|
230
|
-
{ label: '
|
|
258
|
+
{ label: 'Character', short: 'Char', icon: 'inv_misc_head_human_01', view: characterView(app) },
|
|
231
259
|
{ label: 'Spells', icon: 'inv_misc_book_09', view: spellsView(app) },
|
|
232
260
|
{ label: 'Rotation', icon: 'ability_dualwield', view: rotationView(app) },
|
|
233
261
|
{ label: 'Talents', icon: 'spell_nature_naturetouchgrow', view: talentsView(app) },
|
|
234
262
|
{ label: 'Travel', icon: 'spell_nature_swiftness', view: travelView(app) },
|
|
235
263
|
{ label: 'Professions', short: 'Profs', icon: 'trade_blacksmithing', view: professionsView(app) },
|
|
236
264
|
{ label: 'Companions', short: 'Party', icon: 'spell_holy_prayerofhealing', view: companionsView(app) },
|
|
237
|
-
{ label: '
|
|
265
|
+
{ label: 'Settings', icon: 'inv_misc_gear_01', view: settingsDialog(app) },
|
|
238
266
|
];
|
|
239
267
|
}
|
|
240
268
|
|
|
@@ -245,34 +273,42 @@ function worldView(app) {
|
|
|
245
273
|
s: { hint: 'search', fn: (a) => openWorldSearch(a) },
|
|
246
274
|
S: { fn: (a) => openWorldSearch(a, '', true) },
|
|
247
275
|
f: { hint: 'quick fight', fn: (a, row) => { if (row?.data?.creature) a.cmds.commands.fight(String(row.data.creature.entry)); } },
|
|
248
|
-
t: { fn: (a, row) => {
|
|
276
|
+
t: { fn: (a, row) => { if (row?.data?.creature) tame(a, row.data.creature); } },
|
|
249
277
|
P: { fn: (a, row) => { if (row?.data?.creature) pickPocket(a, row.data.creature); } },
|
|
250
278
|
a: { hint: 'autopilot', fn: (a) => a.push(preflightCard(a)) },
|
|
279
|
+
c: { hint: 'characters', fn: (a) => { void a.logout(); } },
|
|
251
280
|
},
|
|
252
281
|
rows(a) {
|
|
253
282
|
const ds = a.ds, g = a.g, z = a.zone(), ch = a.ch;
|
|
254
283
|
if (!z) return [{ text: 'You are nowhere.', header: true }];
|
|
255
284
|
const rows = [...journeyRows(a), ...meetingStoneQueueRows(a), { text: `${z.name} ${dim(`levels ${z.levelRange[0]}-${z.levelRange[1]}${z.faction ? `, ${z.faction}` : ''}`)}`, header: true }];
|
|
285
|
+
// Enemy-faction service NPCs in a contested zone, and any faction you declared war on, will not deal with you (Unit::CanInteract).
|
|
286
|
+
const atWar = g.atWarFactions(), canTalk = (entry) => canInteractWithCreature(ds, ch.race, entry, atWar);
|
|
256
287
|
const people = [];
|
|
257
|
-
for (const t of z.trainers ?? []) if (t.cls === ch.cls) { const d = ds.trainer(t.entry); if (d) people.push({ text: `${d.name} ${dim(`<${className(t.cls)} Trainer>`)}`, detail: () => npcLines(a, d.entry, [bold(d.name), `${className(t.cls)} trainer`, '', dim('Enter: see what you can learn')]), act: () => a.push(trainerDialog(a, d)) }); }
|
|
288
|
+
for (const t of z.trainers ?? []) if (t.cls === ch.cls && canTalk(t.entry)) { const d = ds.trainer(t.entry); if (d) people.push({ text: `${d.name} ${dim(`<${className(t.cls)} Trainer>`)}`, detail: () => npcLines(a, d.entry, [bold(d.name), `${className(t.cls)} trainer`, '', dim('Enter: see what you can learn')]), act: () => a.push(trainerDialog(a, d)) }); }
|
|
289
|
+
for (const wm of zoneWeaponTrainers(ds, z.id)) if (canTalk(wm.entry)) people.push({ text: `${wm.name} ${dim(`<${wm.subname || 'Weapon Master'}>`)}`, detail: () => npcLines(a, wm.entry, [bold(wm.name), wm.subname || 'Weapon Master', 'Teaches weapon skills.', '', dim('Enter: see what you can learn')]), act: () => a.push(trainerDialog(a, wm)) });
|
|
258
290
|
const innEntries = new Set((z.innkeepers ?? []).map((i) => i.entry)); // innkeepers sell too, but list once, as the inn
|
|
259
|
-
for (const v of z.vendors ?? []) { const d = !innEntries.has(v) && ds.vendor(v); if (d) people.push({ text: `${d.name} ${dim(`<${d.subname ?? 'Vendor'}>`)}${d.canRepair ? dim(' repairs') : ''}`, detail: () => npcLines(a, d.entry, [bold(d.name), d.subname ?? 'Vendor', ...(d.canRepair ? ['Repairs armor and weapons'] : []), '', dim('Enter: browse wares')]), act: () => a.push(vendorDialog(a, d)) }); }
|
|
260
|
-
for (const i of z.innkeepers ?? []) people.push({ text: `${i.name} ${dim('<Innkeeper>')}`, detail: () => npcLines(a, i.entry, [bold(i.name), 'Innkeeper', '', dim('Enter: browse wares, make this your home')]), act: () => a.push(innDialog(a, i)) });
|
|
291
|
+
for (const v of z.vendors ?? []) { const d = !innEntries.has(v) && canTalk(v) && ds.vendor(v); if (d) people.push({ text: `${d.name} ${dim(`<${d.subname ?? 'Vendor'}>`)}${d.canRepair ? dim(' repairs') : ''}`, detail: () => npcLines(a, d.entry, [bold(d.name), d.subname ?? 'Vendor', ...(d.canRepair ? ['Repairs armor and weapons'] : []), '', dim('Enter: browse wares')]), act: () => a.push(vendorDialog(a, d)) }); }
|
|
292
|
+
for (const i of z.innkeepers ?? []) if (canTalk(i.entry)) people.push({ text: `${i.name} ${dim('<Innkeeper>')}`, detail: () => npcLines(a, i.entry, [bold(i.name), 'Innkeeper', '', dim('Enter: browse wares, make this your home')]), act: () => a.push(innDialog(a, i)) });
|
|
261
293
|
for (const p of z.profTrainers ?? []) {
|
|
262
|
-
const d = ds.profTrainer(p);
|
|
294
|
+
const d = canTalk(p) && ds.profTrainer(p);
|
|
263
295
|
if (!d) continue;
|
|
264
296
|
const prof = ds.profession(d.skill)?.name ?? d.skill;
|
|
265
297
|
const sk = ch.skills[d.skill];
|
|
266
298
|
people.push({ text: `${d.name} ${dim(`<${prof} Trainer>`)}${sk ? dim(` ${sk.current}/${sk.max}`) : ''}`, detail: () => npcLines(a, d.entry, [bold(d.name), `${prof} trainer`, sk ? `Your skill: ${sk.current}/${sk.max}` : `You do not know ${prof} yet.`, '', dim('Enter: ranks and recipes')]), act: () => a.push(profTrainerDialog(a, d)) });
|
|
267
299
|
}
|
|
268
300
|
people.push(...ridingRows(a));
|
|
269
|
-
|
|
301
|
+
// A flight master serves you once you have found this zone's flight path.
|
|
302
|
+
const found = new Set(ch.discoveredTaxiNodes ?? []);
|
|
303
|
+
const taxiHere = ds.taxiNodes().some((n) => n.zone === z.id && found.has(n.id));
|
|
304
|
+
for (const f of taxiHere ? z.flightMasters ?? [] : []) if (canTalk(f.entry) || !ds.creature(f.entry)) people.push({ text: `${f.name} ${dim('<Flight Master>')}`, detail: () => npcLines(a, f.entry, [bold(f.name), 'Flight master', '', dim('Enter: open the travel tab')]), act: () => a.switchTab(7) });
|
|
270
305
|
// City services: the bank in capitals and banker towns, the auction house in capitals, the mailbox by every inn.
|
|
271
306
|
const city = cityAccess(a);
|
|
272
307
|
const services = [];
|
|
273
308
|
if (city.bank) services.push({ text: `${brightBlue('Bank')} ${dim(`<Banker> ${g.state.bank.stacks.length} stacks stored`)}`, detail: () => [bold('Bank'), 'Store what you do not carry.', '', dim('Enter: open the vault (also b in Bags)')], act: () => a.push(bankDialog(a)) });
|
|
274
309
|
if (city.ah) { const n = g.browseAuctions(Date.now()).length; services.push({ text: `${brightBlue('Auction House')} ${dim(`<Auctioneer>${n ? ` ${n} listings` : ''}`)}`, detail: () => [bold('Auction House'), 'Buy from and sell to the other players of this realm.', '', dim('Enter: browse, sell, see your auctions')], act: () => a.push(auctionDialog(a)) }); }
|
|
275
310
|
if (city.mail) { const unread = g.unreadMailCount(Date.now()); services.push({ text: `${brightBlue('Mailbox')}${unread ? yellow(` ${unread} new`) : ''}`, detail: () => [bold('Mailbox'), 'Auction sales, refunds and overflow loot arrive here.', '', dim('Enter: read your mail')], act: () => a.push(mailDialog(a)) }); }
|
|
311
|
+
rows.push(...hereRows(a));
|
|
276
312
|
if (services.length) rows.push({ text: '', header: true }, { text: 'Services', header: true }, ...services);
|
|
277
313
|
rows.push(...portalRows(a));
|
|
278
314
|
const petRows = petWorldRows(a);
|
|
@@ -287,12 +323,14 @@ function worldView(app) {
|
|
|
287
323
|
if (!dest) continue;
|
|
288
324
|
rows.push({ text: `${cyan(dest.name)} ${dim(`levels ${dest.levelRange[0]}-${dest.levelRange[1]}`)}`, detail: () => travelPreviewLines(a, dest), act: () => travelTo(a, dest) });
|
|
289
325
|
}
|
|
290
|
-
|
|
326
|
+
// What you can attack here: world bosses and quest-summoned targets in, friendly and trigger NPCs out.
|
|
327
|
+
const mobs = huntableCreatures(ds, z.id, g.state.questlog, ch.race).sort((x, y) => x.level - y.level || x.name.localeCompare(y.name));
|
|
291
328
|
if (mobs.length) {
|
|
292
329
|
rows.push({ text: '', header: true }, { text: `Creatures (${mobs.length})`, header: true });
|
|
330
|
+
const targets = questTargets(ds, g.state.questlog);
|
|
293
331
|
for (const m of mobs) {
|
|
294
|
-
const wanted =
|
|
295
|
-
rows.push({ text: `${conPaint(ch.level, m.level)(m.name)} ${dim(`L${m.level}`)}${wanted ? yellow(' !') : ''}${ch.cls === 4 ? pickPocketTag(a, m) : ''}`, data: { creature: m }, detail: () => creatureLines(a, m), act: () => startFight(a, m.entry) });
|
|
332
|
+
const wanted = isQuestMob(ds, m, targets); // a kill target, or a drop source for a quest item you still need
|
|
333
|
+
rows.push({ text: `${conPaint(ch.level, m.level)(m.name)} ${dim(`L${m.level}`)}${wanted ? yellow(' !') : ''}${ch.cls === 4 ? pickPocketTag(a, m) : ''}${tameTag(a, m)}`, data: { creature: m }, detail: () => creatureLines(a, m), act: () => startFight(a, m.entry) });
|
|
296
334
|
}
|
|
297
335
|
}
|
|
298
336
|
// Last: hand-ins and the zone's offers (the Quests tab shows the same list under its log).
|
|
@@ -311,10 +349,11 @@ function worldView(app) {
|
|
|
311
349
|
export function travelPreviewLines(app, dest) {
|
|
312
350
|
const pv = app.g.previewTravel(dest.id);
|
|
313
351
|
const out = [bold(dest.name), dim(`levels ${dest.levelRange[0]}-${dest.levelRange[1]}${dest.faction ? `, ${dest.faction}` : ''}`), ''];
|
|
314
|
-
if (!pv.ok) { out.push(red(pv.reason)); return out; }
|
|
315
|
-
out.push(`${secondsText(pv.totalSeconds * 1000)} of travel${pv.totalCopper ? `, ${
|
|
316
|
-
for (const leg of pv.legs) out.push(dim(` ${leg.kind} ${app.ds.zone(leg.from)?.name ?? leg.from} → ${app.ds.zone(leg.to)?.name ?? leg.to} (${secondsText(leg.seconds * 1000)})`));
|
|
317
|
-
|
|
352
|
+
if (!pv.ok) { out.push(red(pv.reason)); return withWorldMap(app, out, { dest }); }
|
|
353
|
+
out.push(`${secondsText(pv.totalSeconds * 1000)} of travel${pv.totalCopper ? `, ${coinText(pv.totalCopper)} in fares` : ''}`);
|
|
354
|
+
for (const leg of pv.legs) out.push(dim(` ${(leg.transportEntry && app.ds.transports().find((t) => t.entry === leg.transportEntry)?.name) || leg.kind} ${app.ds.zone(leg.from)?.name ?? leg.from} → ${app.ds.zone(leg.to)?.name ?? leg.to} (${secondsText(leg.seconds * 1000)})`));
|
|
355
|
+
// The world map under the plan: you, the destination and the route between them (tuiMap.mjs).
|
|
356
|
+
return withWorldMap(app, out, { dest, legs: pv.legs });
|
|
318
357
|
}
|
|
319
358
|
|
|
320
359
|
export function travelTo(app, dest) {
|
|
@@ -323,7 +362,7 @@ export function travelTo(app, dest) {
|
|
|
323
362
|
const res = app.g.travelTo(dest.id);
|
|
324
363
|
if (!app.result(res)) return;
|
|
325
364
|
app.vendor = null; app.cmds.setVendor(null);
|
|
326
|
-
afterDeparture(app, () => `You travel to ${bold(dest.name)}. ${dim(`${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${
|
|
365
|
+
afterDeparture(app, () => `You travel to ${bold(dest.name)}. ${dim(`${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${coinText(pv.totalCopper)}` : ''}`)}`);
|
|
327
366
|
landOnTab(app);
|
|
328
367
|
}
|
|
329
368
|
|
|
@@ -396,13 +435,18 @@ export function questDialog(app, q, mode) {
|
|
|
396
435
|
const objectives = () => section('Objectives', [...text(q.objectivesText ?? '', bold), ...objectiveLines(a, q, s).map((o) => P(` ${o}`))]);
|
|
397
436
|
// Offered, the story leads as the giver tells it; in the log, what is left to do comes first.
|
|
398
437
|
if (mode === 'offer' || s?.status === 'complete') { story(); objectives(); } else { objectives(); story(); }
|
|
399
|
-
section('Rewards',
|
|
438
|
+
section('Rewards', rewardRows(a, q));
|
|
400
439
|
rows.push(P(''));
|
|
440
|
+
// The reward rows can take the cursor for their tooltips, but the page opens on what you came to do.
|
|
441
|
+
const lead = rows.length;
|
|
442
|
+
if (!view.placed) { view.placed = true; view.cursor = lead; }
|
|
401
443
|
if (mode === 'offer') {
|
|
402
444
|
rows.push({ text: green('Accept the quest'), act: () => { if (a.result(a.g.acceptQuest(q.entry), `Quest accepted: ${q.title}`)) a.pop(); } });
|
|
445
|
+
rows.push({ text: 'Accept and do the quest', act: () => { if (a.result(a.g.acceptQuest(q.entry), `Quest accepted: ${q.title}`)) doQuest(a, q); } });
|
|
403
446
|
rows.push({ text: dim('Decline'), act: () => a.pop() });
|
|
404
447
|
} else {
|
|
405
448
|
if (s?.status === 'complete') rows.push({ text: green('Complete the quest'), act: () => turnIn(a, q) });
|
|
449
|
+
else if (s?.status === 'active') rows.push(...questStepRows(a, q, s));
|
|
406
450
|
// A repeatable with a fixed reward hands in every round the bags pay for in one go.
|
|
407
451
|
const rounds = s?.status === 'complete' && isRepeatableQuest(q) && questRewardChoices(q).length <= 1 ? questRepeatsAvailable(q, a.g.state.inventory, a.ch.zoneId) : 0;
|
|
408
452
|
if (rounds >= 2) rows.push({ text: green(`Hand it in ${rounds} times`), act: () => repeatTurnIn(a, q) });
|
|
@@ -417,6 +461,49 @@ export function questDialog(app, q, mode) {
|
|
|
417
461
|
return view;
|
|
418
462
|
}
|
|
419
463
|
|
|
464
|
+
// The Garments quests' patient, by quest (the script table is keyed by patient).
|
|
465
|
+
const GARMENTS_PATIENT = Object.fromEntries(Object.entries(GARMENTS_ENTRY_QUEST).map(([entry, quest]) => [quest, Number(entry)]));
|
|
466
|
+
|
|
467
|
+
/** Speak target for a gossip-event quest: the mid-event NPC its objectives name (Great Bear Spirit) before the
|
|
468
|
+
* turn-in ender (Dendrite). Ported from phonecraft src/ui/quests/questCardUi.ts eventTalkSpeakName. */
|
|
469
|
+
function eventTalkSpeakName(q) {
|
|
470
|
+
const text = q.objectivesText ?? '';
|
|
471
|
+
for (const re of [/\blisten as ([^.]+?) tells/i, /\bseek out the ([^.]+?) in /i, /\bafter speaking with ([^.]+?)\.?$/i, /\bspeak(?:ing)? with ([^.]+?)(?:\.|,| after)/i, /\bspeak to ([^.]+?)(?:\.|,| again)/i]) {
|
|
472
|
+
const m = text.match(re);
|
|
473
|
+
if (m?.[1]?.trim()) return m[1].trim();
|
|
474
|
+
}
|
|
475
|
+
return (q.enderName || q.starterName || '').trim();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The steps on an open quest's page that are a press, not a fight: speak to someone, examine the object that
|
|
479
|
+
* ends it, finish an escort the data has no path for, or cast on the Garments patient. Ref questCardUi.ts:254. */
|
|
480
|
+
function questStepRows(a, q, s) {
|
|
481
|
+
const g = a.g, ds = a.ds, rows = [];
|
|
482
|
+
const eventTalk = isEventTalkQuest(q);
|
|
483
|
+
const npc = eventTalk ? eventTalkSpeakName(q) : (q.enderName || q.starterName || '').trim();
|
|
484
|
+
if (npc && ((isTalkToNpcQuest(q) && s.talked !== true) || (eventTalk && s.explored !== true))) {
|
|
485
|
+
// On 72 of these the ender is an object: the credit is in using it, and a Speak would be refused.
|
|
486
|
+
const go = q.goEnder || (q.goEnderEntries ?? []).find((e) => e > 0) || 0;
|
|
487
|
+
const goName = go ? ds.gameObject?.(go)?.name ?? npc : '';
|
|
488
|
+
rows.push(go
|
|
489
|
+
? { text: green(`Examine ${goName}`), act: () => a.result(g.interactObject(go), `You examine ${goName}.`) }
|
|
490
|
+
: { text: green(`Speak to ${npc}`), act: () => a.result(g.talkToNpc({ npcName: npc }), `You speak to ${npc}.`) });
|
|
491
|
+
}
|
|
492
|
+
if (eventCreditIsTheRemainingWork(q, s, g.state.inventory)) {
|
|
493
|
+
const kind = q.exploreTriggers?.length ? 'exploration' : 'escort';
|
|
494
|
+
rows.push({ text: green(`Complete the ${kind}`), detail: () => [bold(`Complete the ${kind}`), '', `The data has no path for this ${kind}, so it is credited in one step.`], act: () => a.result(g.finishEscort(q.entry), `${q.title}: ${kind} done.`) });
|
|
495
|
+
}
|
|
496
|
+
const patient = GARMENTS_PATIENT[q.entry];
|
|
497
|
+
if (patient != null) {
|
|
498
|
+
const who = ds.creature(patient)?.name ?? `NPC ${patient}`;
|
|
499
|
+
for (const [id, label] of [[SPELL_LESSER_HEAL_R2, `Heal ${who}`], [SPELL_FORTITUDE_R1, `Fortitude on ${who}`]]) {
|
|
500
|
+
if (!knows(g.state.spellbook, id)) continue;
|
|
501
|
+
rows.push({ text: green(label), act: () => { if (g.spellCooldownRemaining(id) > 0) { a.flashMsg('Not ready yet.'); return; } a.result(g.castOutOfCombat(id, { targetCreature: patient }), `You cast ${a.spellName(id)} on ${who}.`); } });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return rows;
|
|
505
|
+
}
|
|
506
|
+
|
|
420
507
|
/** Do Quest: the autopilot works this one quest (travel, fights, objects, hand-in) and stops when it is done. */
|
|
421
508
|
function doQuest(app, q) {
|
|
422
509
|
const s = app.g.state.questlog.quests.find((x) => x.entry === q.entry);
|
|
@@ -430,7 +517,7 @@ function repeatTurnIn(app, q) {
|
|
|
430
517
|
if (!app.result(r)) return;
|
|
431
518
|
const bits = [`${r.turnedIn} times`];
|
|
432
519
|
if (r.xp) bits.push(`+${r.xp} xp`);
|
|
433
|
-
if (r.money) bits.push(`+${
|
|
520
|
+
if (r.money) bits.push(`+${coinText(r.money)}`);
|
|
434
521
|
app.say(green(`Quest complete: ${q.title}`) + dim(` ${bits.join(', ')}`));
|
|
435
522
|
for (const lv of r.leveledTo) { app.say(bold(green(`You have reached level ${lv}!`))); app.flashMsg(`Level ${lv}!`); }
|
|
436
523
|
app.stack = [];
|
|
@@ -453,7 +540,7 @@ function turnIn(app, q) {
|
|
|
453
540
|
if (!app.result(res, `Quest complete: ${q.title}`)) return;
|
|
454
541
|
const bits = [];
|
|
455
542
|
if (res.xp) bits.push(`+${res.xp} xp`);
|
|
456
|
-
if (res.money) bits.push(`+${
|
|
543
|
+
if (res.money) bits.push(`+${coinText(res.money)}`);
|
|
457
544
|
for (const it of res.items) bits.push(app.itemLabel(it.item) + (it.count > 1 ? ` x${it.count}` : ''));
|
|
458
545
|
for (const s of res.spells ?? []) bits.push(`learned ${s.name ?? ''}`.trim());
|
|
459
546
|
if (bits.length) app.say(' ' + bits.join(', '));
|
|
@@ -467,6 +554,7 @@ function turnIn(app, q) {
|
|
|
467
554
|
if (q.rewChoiceItems?.length) {
|
|
468
555
|
app.pick('Choose your reward', q.rewChoiceItems.map((it, i) => ({
|
|
469
556
|
label: `${app.itemLabel(it.item)}${it.count > 1 ? ` x${it.count}` : ''}`,
|
|
557
|
+
icon: app.ds.item(it.item)?.icon, sub: dim(app.ds.item(it.item)?.typeName ?? ''),
|
|
470
558
|
detail: () => itemLines(app, app.ds.item(it.item)),
|
|
471
559
|
value: i,
|
|
472
560
|
})), finish);
|
|
@@ -486,6 +574,8 @@ function bagsView(app) {
|
|
|
486
574
|
S: { hint: 'sort', fn: (a) => sortBags(a) },
|
|
487
575
|
D: { hint: 'disenchant all', fn: (a) => a.push(bulkDisenchantDialog(a)) },
|
|
488
576
|
L: { hint: 'learn all', fn: (a) => a.push(learnAllDialog(a)) },
|
|
577
|
+
X: { hint: 'destroy several', fn: (a) => a.push(destroyManyDialog(a)) },
|
|
578
|
+
Q: { hint: 'destroy quest leftovers', fn: (a) => a.push(destroyManyDialog(a, 'quest')) },
|
|
489
579
|
A: { fn: onStack(useAll) },
|
|
490
580
|
'/': { fn: onStack(splitStack) },
|
|
491
581
|
'[': { fn: onStack((a, i) => moveStack(a, i, -1)) },
|
|
@@ -497,7 +587,7 @@ function bagsView(app) {
|
|
|
497
587
|
rows(a) {
|
|
498
588
|
const st = a.g.state.inventory.stacks;
|
|
499
589
|
const ups = new Set(a.g.suggestedEquipmentUpgrades().filter((u) => !u.holderId).map((u) => u.entry));
|
|
500
|
-
const rows = [{ text: `${st.length}/${bagCapacity(a.ch, a.ds)} slots ${
|
|
590
|
+
const rows = [{ text: `${st.length}/${bagCapacity(a.ch, a.ds)} slots ${coinText(a.ch.gold)}${ups.size ? green(` ▲ ${plural(ups.size, 'upgrade')} to wear (u)`) : ''}`, header: true }];
|
|
501
591
|
st.forEach((s, i) => {
|
|
502
592
|
const it = a.ds.item(s.item);
|
|
503
593
|
const name = qualityPaint(it?.quality)(it?.name ?? `item ${s.item}`);
|
|
@@ -505,7 +595,7 @@ function bagsView(app) {
|
|
|
505
595
|
const tag = s.wrappedItemEntry ? dim(' gift') : it?.slot ? dim(` ${it.slot}`) : it?.consumable ? dim(` ${it.consumable.effect}`) : verb === 'Open' ? dim(' open') : verb === 'Learn' ? dim(' learn') : it?.questItem ? dim(' quest') : '';
|
|
506
596
|
rows.push({
|
|
507
597
|
text: `${name}${s.count > 1 ? ` x${s.count}` : ''}${tag}${ups.has(s.item) ? green(' ▲') : ''}`, data: s,
|
|
508
|
-
icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${
|
|
598
|
+
icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${coinText(it.sellPrice * s.count)}` : ''}`),
|
|
509
599
|
detail: () => itemLines(a, it, [
|
|
510
600
|
'', dim(`Enter: ${verb ? verb.toLowerCase() : 'more'} m: more${s.count > 1 ? ' /: split' : ''} [ ]: move d: destroy`),
|
|
511
601
|
...(a.vendor ? [dim(`s: sell to ${a.vendor.name}`)] : []),
|
|
@@ -556,10 +646,13 @@ function compareLines(app, it, worn) {
|
|
|
556
646
|
return out;
|
|
557
647
|
}
|
|
558
648
|
|
|
559
|
-
|
|
649
|
+
/** The Character tab: the paperdoll (your gear around your body), your numbers under the tooltip, s for the rest. */
|
|
650
|
+
function characterView(app) {
|
|
560
651
|
return {
|
|
561
|
-
id: 'gear', title: '
|
|
652
|
+
id: 'gear', title: 'Character', hint: 'Enter: change what you wear',
|
|
653
|
+
detailFoot: (a, w) => statPanel(a, w),
|
|
562
654
|
keys: {
|
|
655
|
+
s: { hint: 'stats', fn: (a) => a.push(characterDetailsDialog(a)) },
|
|
563
656
|
u: { hint: 'equip all upgrades', fn: (a) => {
|
|
564
657
|
if (!upgradeList(a).length) { a.flashMsg("Nothing in your bags or your companions' beats what you wear."); return; }
|
|
565
658
|
// Best first, re-scored after each piece (two rings, a two-hander). Works in suggest mode too,
|
|
@@ -581,12 +674,13 @@ function gearView(app) {
|
|
|
581
674
|
o: { hint: 'outfits', fn: (a) => a.push(outfitterDialog(a)) },
|
|
582
675
|
},
|
|
583
676
|
// Drawn as the character sheet (tuiPaperdoll.mjs) when colour is on and the window has room.
|
|
584
|
-
grid: (a, rows, w, h) => paperdollGrid(a, rows, w, h, (cols, lines) => playerArt(a, cols, lines)?.lines ?? null),
|
|
677
|
+
grid: (a, rows, w, h) => paperdollGrid(a, rows, w, h, (cols, lines) => playerArt(a, cols, lines)?.lines ?? null, characterTitle(a)),
|
|
585
678
|
rows(a) {
|
|
586
679
|
const rows = [];
|
|
587
680
|
const ups = upgradeList(a);
|
|
588
681
|
const extras = gearExtraRows(a);
|
|
589
682
|
const sheet = paperdollPlan(a, extras.filter((r) => !r.header).length);
|
|
683
|
+
if (!sheet) rows.push({ text: `${characterTitle(a)} ${dim('s: stats, skills, auras, reputation')}`, header: true, plain: true }, { text: '', header: true });
|
|
590
684
|
if (ups.length && !sheet) {
|
|
591
685
|
rows.push({ text: `${green('▲')} Better gear for you (${ups.length}) ${dim('Enter: put on u: put on all')}`, header: true });
|
|
592
686
|
for (const u of ups) {
|
|
@@ -642,8 +736,8 @@ function slotDialog(app, slot) {
|
|
|
642
736
|
const low = it.requiredLevel > ch.level;
|
|
643
737
|
const name = qualityPaint(it.quality)(it.name);
|
|
644
738
|
return {
|
|
645
|
-
text:
|
|
646
|
-
data: { s, it },
|
|
739
|
+
text: `${low ? dim(name) : name}${s.count > 1 ? dim(` x${s.count}`) : ''}${up ? green(` ▲ +${Math.round(up.delta)}`) : ''}${low ? red(` L${it.requiredLevel}`) : ''}${from === 'bank' ? dim(' in bank') : ''}${comp ? cyan(` ${comp.name}`) : ''}`,
|
|
740
|
+
data: { s, it }, icon: it.icon, sub: dim([it.typeName, it.armor ? `${it.armor} armor` : '', it.weapon ? `${it.weapon.dmgMin}-${it.weapon.dmgMax}` : ''].filter(Boolean).join(' ')),
|
|
647
741
|
detail: () => itemLines(a, it, [...compareLines(a, it, worn), '', dim(from === 'bank' ? 'Enter: take it out of the bank and wear it' : comp ? `Enter: take it from ${comp.name} and wear it` : 'Enter: wear it')]),
|
|
648
742
|
act: () => {
|
|
649
743
|
if (from === 'bank' && !a.result(a.g.bankWithdraw(s.item, 1), '')) return;
|
|
@@ -672,15 +766,63 @@ function slotDialog(app, slot) {
|
|
|
672
766
|
};
|
|
673
767
|
}
|
|
674
768
|
|
|
769
|
+
/**
|
|
770
|
+
* The spellbook's tabs, as the client draws them: General, then one per talent tree. A spell goes on the tab
|
|
771
|
+
* of its class skill line (SkillLineAbility, baked as combatSkillMeta.spellAbilities); the line is matched to
|
|
772
|
+
* its tree by name, since the trees carry no skill id ("Shadow" is Shadow Magic, "Elemental" is Elemental
|
|
773
|
+
* Combat). Everything else (Attack, racials, Poisons, Beast Training) is General.
|
|
774
|
+
*/
|
|
775
|
+
function spellbookTabs(ch, ds) {
|
|
776
|
+
const meta = ds.combatSkillMeta(), bit = 1 << (ch.cls - 1);
|
|
777
|
+
const trees = ds.talentTrees(ch.cls).map((t) => t.name);
|
|
778
|
+
const treeOfSkill = (skillId) => {
|
|
779
|
+
if (meta?.categories?.[skillId] !== 7) return -1;
|
|
780
|
+
const n = (meta.skillNames?.[skillId] ?? '').toLowerCase();
|
|
781
|
+
return trees.findIndex((t) => n === t.toLowerCase() || n.startsWith(`${t.toLowerCase()} `));
|
|
782
|
+
};
|
|
783
|
+
const tabOf = (sp) => {
|
|
784
|
+
for (const r of meta?.spellAbilities?.[sp.id] ?? []) {
|
|
785
|
+
if (r.classMask && !(r.classMask & bit)) continue;
|
|
786
|
+
const k = treeOfSkill(r.skillId);
|
|
787
|
+
if (k >= 0) return k + 1;
|
|
788
|
+
}
|
|
789
|
+
return 0;
|
|
790
|
+
};
|
|
791
|
+
return { names: ['General', ...trees], tabOf };
|
|
792
|
+
}
|
|
793
|
+
|
|
675
794
|
function spellsView(app) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
795
|
+
// Until a tab is picked the book opens on the first one with spells (a warrior's General can be empty).
|
|
796
|
+
let book = null;
|
|
797
|
+
const turn = (a, d) => { a.rowsOf(view); const n = spellbookTabs(a.ch, a.ds).names.length; book = (book + d + n) % n; view.scroll = 0; };
|
|
798
|
+
const view = {
|
|
799
|
+
id: 'spells', title: 'Spellbook', hint: 'Enter: cast [ ]: tab',
|
|
800
|
+
keys: {
|
|
801
|
+
b: { hint: 'action bars', fn: (a) => a.push(barsDialog(a, playerBarsOwner)) }, m: { hint: 'macros', fn: (a) => a.push(macrosDialog(a)) },
|
|
802
|
+
'[': { fn: (a) => turn(a, -1) }, ']': { fn: (a) => turn(a, +1) },
|
|
803
|
+
},
|
|
804
|
+
// Left Right on the tab strip turn the book's tabs; anywhere else they still change the window's tabs.
|
|
805
|
+
onKey(a, k, row) {
|
|
806
|
+
if (!row?.data?.strip || (k !== 'left' && k !== 'right')) return false;
|
|
807
|
+
turn(a, k === 'right' ? +1 : -1);
|
|
808
|
+
return true;
|
|
809
|
+
},
|
|
679
810
|
rows(a) {
|
|
680
|
-
const
|
|
811
|
+
const tabs = spellbookTabs(a.ch, a.ds);
|
|
812
|
+
const all = knownUsableSpells(a.ch, a.g.state.spellbook, a.ds);
|
|
813
|
+
const counts = tabs.names.map((_, k) => all.filter((sp) => tabs.tabOf(sp) === k).length);
|
|
814
|
+
book = book == null ? Math.max(0, counts.findIndex((n) => n > 0)) : Math.min(book, tabs.names.length - 1);
|
|
815
|
+
const known = all.filter((sp) => tabs.tabOf(sp) === book).sort((x, y) => x.name.localeCompare(y.name));
|
|
816
|
+
const L = a.layout(), width = (L.detailW ? L.listW : L.W) - 2;
|
|
681
817
|
const head = [{ text: `Action bars ${dim(`${a.g.state.actionBars?.length ?? 0} bars b`)}`, detail: () => [bold('Action bars'), '', 'Bar 1 is the number row in a fight: put spells and bag items on it and bind keys.'], act: () => a.push(barsDialog(a, playerBarsOwner)) },
|
|
682
818
|
{ text: `Macros ${dim(`${a.g.macros().length} m`)}`, detail: () => [bold('Macros'), '', 'Several casts, uses or gear swaps on one key, with conditions. Out of a fight r runs one; in a fight its key on bar 1 fires it.'], act: () => a.push(macrosDialog(a)) },
|
|
683
819
|
{ text: '', header: true }];
|
|
820
|
+
head.push({
|
|
821
|
+
text: tabStrip(tabs.names.map((n, k) => `${n} ${counts[k]}`), book, width - 1, view.cursor === head.length), data: { strip: true },
|
|
822
|
+
detail: () => [bold('Spellbook tabs'), '', 'General, then one tab per talent tree, the way the trainer files them.', '', dim('Left Right here, or [ ] anywhere: turn the tabs'), dim('Down: the spells')],
|
|
823
|
+
act: (a2) => a2.moveCursor(view, a2.rowsOf(view), +1),
|
|
824
|
+
}, { text: '', header: true });
|
|
825
|
+
if (!known.length) head.push({ text: dim('No spells on this tab yet.'), header: true });
|
|
684
826
|
return [...head, ...known.map((sp) => {
|
|
685
827
|
const cd = a.g.spellCooldownRemaining(sp.id);
|
|
686
828
|
return {
|
|
@@ -692,6 +834,7 @@ function spellsView(app) {
|
|
|
692
834
|
})];
|
|
693
835
|
},
|
|
694
836
|
};
|
|
837
|
+
return view;
|
|
695
838
|
}
|
|
696
839
|
|
|
697
840
|
// ---- rotation ------------------------------------------------------------------------------------
|
|
@@ -781,8 +924,10 @@ function profileMenu(app) {
|
|
|
781
924
|
|
|
782
925
|
function rotationView(app) {
|
|
783
926
|
const rebuild = (a) => a.confirm('Rebuild the recommended rotation? The edits are lost.', () => { rotOwner(a).reset(); a.say(green('Rotation rebuilt.')); });
|
|
784
|
-
|
|
927
|
+
const view = {
|
|
785
928
|
id: 'rotation', title: 'Rotation', hint: 'Enter: open',
|
|
929
|
+
// The phases Enter has opened; all start closed so the tab fits a screen.
|
|
930
|
+
open: new Set(),
|
|
786
931
|
keys: {
|
|
787
932
|
w: { hint: 'whose', fn: (a) => pickRotationOwner(a) },
|
|
788
933
|
p: { hint: 'profiles', fn: (a) => profileMenu(a) },
|
|
@@ -796,6 +941,7 @@ function rotationView(app) {
|
|
|
796
941
|
d: { fn: (a, row) => row?.data?.rule && a.confirm('Delete this rule?', () => deleteRule(a, row.data)) },
|
|
797
942
|
s: { hint: 'spec', fn: (a) => { if (!rotOwner(a).comp) pickSpec(a); } },
|
|
798
943
|
r: { hint: 'rebuild', fn: rebuild },
|
|
944
|
+
e: { hint: 'open all', fn: (a) => { const all = PHASES.filter((ph) => (rotOwner(a).get().phases[ph] ?? []).length || ph !== 'prebuff'); if (all.every((ph) => view.open.has(ph))) view.open.clear(); else all.forEach((ph) => view.open.add(ph)); } },
|
|
799
945
|
},
|
|
800
946
|
rows(a) {
|
|
801
947
|
const g = a.g, o = rotOwner(a), rot = o.get(), names = rotNames(a);
|
|
@@ -805,18 +951,28 @@ function rotationView(app) {
|
|
|
805
951
|
{ text: `${bold(o.comp ? `${o.comp.name}: ${spec}` : `${spec} rotation`)}${rot.custom ? dim(' (edited)') : dim(' (recommended)')}${!o.comp && g.rotationNeedsRefresh() ? yellow(' a newer recommended list exists') : ''}`, header: true },
|
|
806
952
|
...((g.state.squad ?? []).length ? [{ text: `Whose: ${cyan(o.comp ? o.comp.name : 'You')} ${dim('w')}`, detail: () => [bold('Whose rotation'), '', "Your own, or a seated companion's: the same rules drive them in every fight."], act: () => pickRotationOwner(a) }] : []),
|
|
807
953
|
{ text: `Profile: ${cyan(prof?.name ?? 'Default')} ${dim(`${o.profiles().length} saved p`)}`, detail: () => [bold('Rotation profiles'), '', 'Keep several named rotations and switch which one plays.', ...o.profiles().map((p) => `${p.active ? green('●') : dim('·')} ${p.name}`)], act: () => profileMenu(a) },
|
|
808
|
-
...wrapLines('What auto-play casts for you. Each tick the rules are read top to bottom and the first one whose conditions hold fires. ✓ is on, · is off. Enter on a rule opens its menu.', a.layout().listW - 2).map((t) => ({ text: dim(t), header: true })),
|
|
954
|
+
...wrapLines('What auto-play casts for you. Each tick the rules are read top to bottom and the first one whose conditions hold fires. ✓ is on, · is off. Enter on a phase opens it, Enter on a rule opens its menu.', a.layout().listW - 2).map((t) => ({ text: dim(t), header: true })),
|
|
809
955
|
];
|
|
810
956
|
for (const phase of PHASES) {
|
|
811
957
|
const rules = rot.phases[phase] ?? [];
|
|
812
958
|
if (!rules.length && phase === 'prebuff') continue;
|
|
813
|
-
|
|
959
|
+
// A phase is one row until Enter opens it; closed, it previews the first few spells it casts.
|
|
960
|
+
const open = view.open.has(phase), on = rules.filter((r) => r.enabled);
|
|
961
|
+
const [title, gloss = ''] = PHASE_LABEL[phase].split(/ (?=\()/);
|
|
962
|
+
const preview = on.slice(0, 4).map((r) => { const id = r.action?.spellId ?? r.before?.spellId; return id ? names.spell(id) : ruleSentence(r, names); }).join(', ') + (on.length > 4 ? ', …' : '');
|
|
963
|
+
rows.push({ text: '', header: true }, {
|
|
964
|
+
text: `${open ? '▾' : '▸'} ${bold(title)} ${dim(gloss)} ${cyan(plural(rules.length, 'rule'))}${!open && preview ? dim(` ${preview}`) : ''}`,
|
|
965
|
+
data: { phase, toggle: true },
|
|
966
|
+
detail: () => [bold(PHASE_LABEL[phase]), '', ...(rules.length ? rules.map((r, i) => `${dim(`${i + 1}.`)} ${r.enabled ? green('✓') : dim('·')} ${ruleSentence(r, names)}`) : [dim('No rules yet.')]), '', dim(open ? 'Enter: close a: add a rule' : 'Enter: open to see and edit the rules a: add a rule')],
|
|
967
|
+
act: () => { if (open) view.open.delete(phase); else view.open.add(phase); },
|
|
968
|
+
});
|
|
969
|
+
if (!open) continue;
|
|
814
970
|
rules.forEach((rule, index) => {
|
|
815
971
|
const conds = conditionsSentence(rule.conditions, names);
|
|
816
972
|
// With icons a rule is two lines and the condition takes the second; without, it trails the sentence.
|
|
817
973
|
const icon = iconsOn() ? a.ds.spell(rule.action?.spellId ?? rule.before?.spellId)?.icon : undefined;
|
|
818
974
|
const when = conds !== 'always' ? dim(`when ${conds}`) : '';
|
|
819
|
-
const text =
|
|
975
|
+
const text = ` ${rule.enabled ? green('✓') : dim('·')} ${rule.enabled ? ruleSentence(rule, names) : dim(ruleSentence(rule, names))}${!icon && when ? ` ${when}` : ''}`;
|
|
820
976
|
rows.push({
|
|
821
977
|
text, data: { phase, index, rule },
|
|
822
978
|
icon, sub: when,
|
|
@@ -825,7 +981,7 @@ function rotationView(app) {
|
|
|
825
981
|
});
|
|
826
982
|
});
|
|
827
983
|
rows.push({
|
|
828
|
-
text: dim(
|
|
984
|
+
text: dim(` + Add a rule to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}`), data: { phase },
|
|
829
985
|
detail: () => [bold(PHASE_LABEL[phase]), '', dim('Enter picks a spell or a combat item to add at the end of this list. Afterwards you can say when it fires.')],
|
|
830
986
|
act: () => addRule(a, phase),
|
|
831
987
|
});
|
|
@@ -844,6 +1000,7 @@ function rotationView(app) {
|
|
|
844
1000
|
return rows;
|
|
845
1001
|
},
|
|
846
1002
|
};
|
|
1003
|
+
return view;
|
|
847
1004
|
}
|
|
848
1005
|
|
|
849
1006
|
/** Enter on a rule: everything you can do with it, in one list. */
|
|
@@ -918,6 +1075,7 @@ function addRule(app, phase, afterIndex) {
|
|
|
918
1075
|
} else if (picked.kind === 'item') {
|
|
919
1076
|
({ action, conditions } = defaultItemRule(app, o, picked.itemEntry));
|
|
920
1077
|
}
|
|
1078
|
+
app.tabs[5].view.open.add(phase);
|
|
921
1079
|
editRotation(app, (rot) => {
|
|
922
1080
|
const rule = { id, action, conditions, enabled: true };
|
|
923
1081
|
const list = rot.phases[phase] ??= [];
|
|
@@ -953,7 +1111,7 @@ function talentsView(app) {
|
|
|
953
1111
|
grid: (a, rows, w, h) => talentGrid(a, rows, w, h),
|
|
954
1112
|
keys: {
|
|
955
1113
|
u: { hint: 'refund', fn: (a, row) => row?.data && a.result(a.g.refundTalent(row.data.id), `Refunded a point from ${row.data.name}.`) },
|
|
956
|
-
R: { hint: 'respec', fn: (a) => { const cost = a.g.respecCost(); a.confirm(`Reset all talents for ${
|
|
1114
|
+
R: { hint: 'respec', fn: (a) => { const cost = a.g.respecCost(); a.confirm(`Reset all talents for ${coinText(cost, 'free')}?`, () => a.result(a.g.respec(), 'Your talents are reset.')); } },
|
|
957
1115
|
B: { hint: 'builds', fn: (a) => talentBuildsMenu(a) },
|
|
958
1116
|
},
|
|
959
1117
|
rows(a) {
|
|
@@ -1010,27 +1168,40 @@ function talentLinesText(app, t, rank, can) {
|
|
|
1010
1168
|
function travelView(app) {
|
|
1011
1169
|
return {
|
|
1012
1170
|
id: 'travel', title: 'Travel', hint: 'Enter: go',
|
|
1171
|
+
// A row without its own detail (a teleport, a portal) still shows the world map with you on it.
|
|
1172
|
+
detail(a) { return withWorldMap(a, [bold(a.zone()?.name ?? a.ch.zoneId), dim('The world, and where you stand in it.')], {}); },
|
|
1013
1173
|
rows(a) {
|
|
1014
1174
|
const g = a.g, ds = a.ds, z = a.zone(), ch = a.ch;
|
|
1015
|
-
const rows = [...journeyRows(a), { text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }
|
|
1016
|
-
|
|
1175
|
+
const rows = [...journeyRows(a), { text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }];
|
|
1176
|
+
// A zone's neighbors are every travel edge out of it: a boat, a zeppelin or the tram is not a walk.
|
|
1177
|
+
const edgeTo = new Map(ds.travelEdges().filter((e) => e.a === ch.zoneId || e.b === ch.zoneId).map((e) => [e.a === ch.zoneId ? e.b : e.a, e]));
|
|
1178
|
+
const groups = [['On foot', ['ground', 'portal']], ['Boats and zeppelins', ['transport']], ['Deeprun Tram', ['tram']]];
|
|
1179
|
+
for (const [title, kinds] of groups) {
|
|
1180
|
+
const near = (z?.neighbors ?? []).filter((id) => kinds.includes(edgeTo.get(id)?.kind ?? 'ground')).map((id) => [ds.zone(id), edgeTo.get(id)]).filter(([d]) => d);
|
|
1181
|
+
if (!near.length) continue;
|
|
1182
|
+
rows.push({ text: '', header: true }, { text: title, header: true });
|
|
1183
|
+
for (const [d, e] of near) {
|
|
1184
|
+
const line = e?.kind === 'transport' ? ds.transports().find((t) => t.entry === e.transport)?.name : null;
|
|
1185
|
+
rows.push({ text: `${d.name} ${dim(`L${d.levelRange[0]}-${d.levelRange[1]}`)}${line ? dim(` ${line}`) : ''}`, detail: () => travelPreviewLines(a, d), act: () => travelTo(a, d) });
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1017
1188
|
const taxi = g.taxiOptions();
|
|
1018
1189
|
if (taxi.length) {
|
|
1019
1190
|
rows.push({ text: '', header: true }, { text: 'Flight paths', header: true });
|
|
1020
|
-
for (const t of taxi) { const d = ds.zone(t.toZone); if (d) rows.push({ text: `${d.name} ${dim(`${secondsText(t.seconds * 1000)}, ${
|
|
1191
|
+
for (const t of taxi) { const d = ds.zone(t.toZone); if (d) rows.push({ text: `${d.name} ${dim(`${secondsText(t.seconds * 1000)}, ${coinText(t.priceCopper)}`)}`, detail: () => travelPreviewLines(a, d), act: () => travelTo(a, d) }); }
|
|
1021
1192
|
}
|
|
1022
1193
|
rows.push({ text: '', header: true }, { text: 'Hearthstone', header: true });
|
|
1023
1194
|
const bind = ch.hearthBind;
|
|
1024
1195
|
const cd = Math.max(0, (ch.hearthCooldownUntil ?? 0) - Date.now());
|
|
1025
1196
|
rows.push({
|
|
1026
1197
|
text: bind ? `Hearth to ${ds.zone(bind.zone)?.name ?? bind.zone}${bind.name ? dim(` (${bind.name})`) : ''}${cd > 0 ? red(` ${secondsText(cd)}`) : ''}` : dim('No home set: speak with an innkeeper.'),
|
|
1027
|
-
detail: () => [bold('Hearthstone'), bind ? `Bound at ${bind.name ?? ds.zone(bind.zone)?.name}` : 'Not bound', cd > 0 ? red(`Ready in ${secondsText(cd)}`) : green('Ready')],
|
|
1198
|
+
detail: () => withWorldMap(a, [bold('Hearthstone'), bind ? `Bound at ${bind.name ?? ds.zone(bind.zone)?.name}` : 'Not bound', cd > 0 ? red(`Ready in ${secondsText(cd)}`) : green('Ready')], bind ? { dest: ds.zone(bind.zone), legs: [{ to: bind.zone }] } : {}),
|
|
1028
1199
|
act: () => { const r = g.castHearth(); if (a.result(r)) { a.vendor = null; afterDeparture(a, () => `You hearth to ${a.zone()?.name}.`); landOnTab(a); } },
|
|
1029
1200
|
});
|
|
1030
1201
|
const ports = g.classTravelOptions();
|
|
1031
1202
|
if (ports.length) {
|
|
1032
1203
|
rows.push({ text: '', header: true }, { text: 'Teleports', header: true });
|
|
1033
|
-
for (const p of ports) rows.push({ text: `${p.name} → ${ds.zone(p.toZone)?.name ?? p.toZone}${p.reagents.some((r) => r.have < r.count) ? red(' no reagent') : ''}`, act: () => { const r = g.castTeleport(p.spellId); if (a.result(r)) { afterDeparture(a, () => `You teleport to ${a.zone()?.name}.`); landOnTab(a); } } });
|
|
1204
|
+
for (const p of ports) rows.push({ text: `${p.name} → ${ds.zone(p.toZone)?.name ?? p.toZone}${p.reagents.some((r) => r.have < r.count) ? red(' no reagent') : ''}`, detail: () => withWorldMap(a, [bold(p.name), dim(`to ${ds.zone(p.toZone)?.name ?? p.toZone}`)], { dest: ds.zone(p.toZone), legs: [{ to: p.toZone }] }), act: () => { const r = g.castTeleport(p.spellId); if (a.result(r)) { afterDeparture(a, () => `You teleport to ${a.zone()?.name}.`); landOnTab(a); } } });
|
|
1034
1205
|
}
|
|
1035
1206
|
rows.push(...portalRows(a));
|
|
1036
1207
|
return rows;
|
|
@@ -1065,25 +1236,110 @@ function weaponSkillRows(a, L) {
|
|
|
1065
1236
|
return rows;
|
|
1066
1237
|
}
|
|
1067
1238
|
|
|
1239
|
+
// How tall a worn model stands against the body it replaces, which fills its box. Sprites are cropped to the
|
|
1240
|
+
// model, so a cat drawn into a person's box came out the size of a person. The client's collision heights are
|
|
1241
|
+
// one placeholder for most of these models, so this goes by shape: the druid's animals and Ghost Wolf stand on
|
|
1242
|
+
// four legs, a transform by its creature's type (a Polymorph sheep is a critter, a ninja costume a humanoid).
|
|
1243
|
+
const FORM_HEIGHT = {
|
|
1244
|
+
[SHAPESHIFT_FORM.CAT]: 0.6, [SHAPESHIFT_FORM.BEAR]: 0.7, [SHAPESHIFT_FORM.DIREBEAR]: 0.7,
|
|
1245
|
+
[SHAPESHIFT_FORM.TRAVEL]: 0.65, [SHAPESHIFT_FORM.AQUA]: 0.5, [SHAPESHIFT_FORM.GHOSTWOLF]: 0.6,
|
|
1246
|
+
};
|
|
1247
|
+
const TYPE_HEIGHT = { [CREATURE_TYPE.BEAST]: 0.6, [CREATURE_TYPE.CRITTER]: 0.35 };
|
|
1248
|
+
const typeByDisplay = new WeakMap(); // ds -> displayId -> the creature type of the first creature drawn with it
|
|
1249
|
+
|
|
1250
|
+
function displayType(ds, displayId) {
|
|
1251
|
+
let m = typeByDisplay.get(ds);
|
|
1252
|
+
if (!m) {
|
|
1253
|
+
m = new Map();
|
|
1254
|
+
for (const c of ds.creatures()) if (c.displayId && !m.has(c.displayId)) m.set(c.displayId, c.creatureType);
|
|
1255
|
+
typeByDisplay.set(ds, m);
|
|
1256
|
+
}
|
|
1257
|
+
return m.get(displayId);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* The model `subject` (you, or a companion) wears right now, or null for its own body: { displayId, label,
|
|
1262
|
+
* height }, height the share of the box it stands in (FORM_HEIGHT). In a fight, `view` (its combat view) has
|
|
1263
|
+
* the live form and auras: a shift or a Polymorph lands there, not on the saved character, whose costumes are
|
|
1264
|
+
* still read under them.
|
|
1265
|
+
*/
|
|
1266
|
+
export function wornNow(ds, subject, view) {
|
|
1267
|
+
const worn = wornModel(ds, subject, view);
|
|
1268
|
+
if (!worn) return null;
|
|
1269
|
+
const form = ((view ? view.form : subject.form) ?? 0) | 0;
|
|
1270
|
+
const height = form && worn.displayId === formDisplayId(form, factionOf(ds, subject.race))
|
|
1271
|
+
? FORM_HEIGHT[form] ?? 1
|
|
1272
|
+
: TYPE_HEIGHT[displayType(ds, worn.displayId)] ?? 1;
|
|
1273
|
+
return { ...worn, height };
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** A worn model (wornNow) as art in a box `maxCols` x `maxRows`, standing its height in it; null without art. */
|
|
1277
|
+
export function wornArt(worn, maxCols, maxRows) {
|
|
1278
|
+
return worn?.displayId ? creatureArt(worn.displayId, maxCols, Math.max(3, Math.round(maxRows * worn.height))) : null;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function wornModel(ds, subject, view) {
|
|
1282
|
+
const now = Date.now();
|
|
1283
|
+
if (!view) return wornDisplay(subject, ds, now);
|
|
1284
|
+
// Polymorph and Hex bake as crowd control, the model swap left in their raw aura 56 row, which
|
|
1285
|
+
// wornDisplay reads past. Being turned into a sheep is the latest model change, so it wins.
|
|
1286
|
+
for (const d of view.debuffs ?? []) {
|
|
1287
|
+
const spell = d.spellId != null ? ds.spell(d.spellId) : null;
|
|
1288
|
+
const raw = spell?.rawEffects?.find((r) => (r.auraName | 0) === 56 && r.miscValue);
|
|
1289
|
+
const displayId = raw ? ds.creature(raw.miscValue | 0)?.displayId : 0;
|
|
1290
|
+
if (displayId) return { displayId, label: spell.name };
|
|
1291
|
+
}
|
|
1292
|
+
const live = [...(view.buffs ?? []), ...(view.debuffs ?? [])].filter((b) => b.spellId != null).map((b) => ({ spellId: b.spellId }));
|
|
1293
|
+
return wornDisplay({ ...subject, form: view.form ?? 0, activeAuras: [...live, ...(subject.activeAuras ?? [])] }, ds, now);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1068
1296
|
/**
|
|
1069
1297
|
* Your character as a sprite for a pane: the form or costume you wear when the creature pack has it, else
|
|
1070
|
-
* you in your gear (paperdoll.mjs), else your bare race and gender body. `
|
|
1071
|
-
* is what shows; null without art.
|
|
1298
|
+
* you in your gear (paperdoll.mjs), else your bare race and gender body. `view` is your combat view in a
|
|
1299
|
+
* fight (wornNow). `label` names the form when that is what shows; null without art.
|
|
1072
1300
|
*/
|
|
1073
|
-
export function playerArt(app, maxCols, maxRows) {
|
|
1301
|
+
export function playerArt(app, maxCols, maxRows, view = null) {
|
|
1074
1302
|
const ch = app.ch;
|
|
1075
|
-
const worn =
|
|
1076
|
-
const
|
|
1077
|
-
if (
|
|
1303
|
+
const worn = wornNow(app.ds, ch, view);
|
|
1304
|
+
const drawn = wornArt(worn, maxCols, maxRows);
|
|
1305
|
+
if (drawn) return { lines: drawn, label: worn.label };
|
|
1078
1306
|
const geared = paperdollArt(ch, maxCols, maxRows);
|
|
1079
1307
|
if (geared) return { lines: geared, label: null };
|
|
1080
1308
|
const own = creatureArt(nativeDisplayId(ch.race, ch.gender), maxCols, maxRows);
|
|
1081
1309
|
return own ? { lines: own, label: null } : null;
|
|
1082
1310
|
}
|
|
1083
1311
|
|
|
1084
|
-
|
|
1312
|
+
/** Name, level, race and class: the Character tab's title line. */
|
|
1313
|
+
function characterTitle(a) {
|
|
1314
|
+
const ch = a.ch;
|
|
1315
|
+
return `${bold(ch.name)} ${dim(`level ${ch.level} ${raceName(ch.race)} ${className(ch.cls)}`)}`;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** Your numbers at the foot of the Character tab's tooltip pane: health, power, experience, money, then the
|
|
1319
|
+
* attributes beside the combat ratings, the way the client's stat frames sit under the paperdoll. */
|
|
1320
|
+
function statPanel(a, w) {
|
|
1321
|
+
const ch = a.ch, ds = a.ds;
|
|
1322
|
+
const st = effectiveStats(ch, ds);
|
|
1323
|
+
const pt = powerType(ch);
|
|
1324
|
+
const pct = (x) => `${((x ?? 0) * 100).toFixed(2)}%`;
|
|
1325
|
+
const combat = [
|
|
1326
|
+
['Armor', effectiveArmor(ch, ds)], ['Attack pwr', attackPower(ch, ds)], ['Crit', pct(critChanceDisplay(ch, ds))],
|
|
1327
|
+
['Hit', pct(hitChance(ch, ds))], ['Dodge', pct(dodgeChanceDisplay(ch, ds))],
|
|
1328
|
+
];
|
|
1329
|
+
const attrs = Object.entries(STAT_LABEL).map(([k, name]) => `${fit(name, 10)}${String(st[k]).padStart(5)}`);
|
|
1330
|
+
return [
|
|
1331
|
+
dim('─'.repeat(Math.max(0, w))),
|
|
1332
|
+
`Health ${ch.health}/${maxHealth(ch, ds)} ${pt[0].toUpperCase() + pt.slice(1)} ${ch.power}/${maxPower(ch, ds)}`,
|
|
1333
|
+
`XP ${ch.xp}/${ds.xpToNext(ch.level)}${ch.restedXp ? dim(` (${ch.restedXp} rested)`) : ''} ${coinText(ch.gold)}`,
|
|
1334
|
+
'',
|
|
1335
|
+
...attrs.map((t, i) => `${t} ${fit(combat[i][0], 10)} ${String(combat[i][1]).padStart(7)}`),
|
|
1336
|
+
];
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/** s on the Character tab: the whole sheet in words: stats, weapon skills, professions, auras, home, pet, honor, reputation. */
|
|
1340
|
+
function characterDetailsDialog(app) {
|
|
1085
1341
|
return {
|
|
1086
|
-
id: 'char', title: '
|
|
1342
|
+
id: 'char', title: 'Stats', hint: 'Esc: back',
|
|
1087
1343
|
/** The detail pane holds your character: the body of your race and gender, or the form you are in. */
|
|
1088
1344
|
detail(a) {
|
|
1089
1345
|
const L = a.layout(), ch = a.ch;
|
|
@@ -1100,7 +1356,7 @@ function charView(app) {
|
|
|
1100
1356
|
L(`${bold(ch.name)} level ${ch.level} ${className(ch.cls)}`),
|
|
1101
1357
|
L(''),
|
|
1102
1358
|
L(`Health ${ch.health}/${maxHealth(ch, ds)} ${pt[0].toUpperCase() + pt.slice(1)} ${ch.power}/${maxPower(ch, ds)}`),
|
|
1103
|
-
L(`Experience ${ch.xp}/${ds.xpToNext(ch.level)}${ch.restedXp ? dim(` (${ch.restedXp} rested)`) : ''} ${
|
|
1359
|
+
L(`Experience ${ch.xp}/${ds.xpToNext(ch.level)}${ch.restedXp ? dim(` (${ch.restedXp} rested)`) : ''} ${coinText(ch.gold)}`),
|
|
1104
1360
|
L(''),
|
|
1105
1361
|
L(cyan('Attributes')),
|
|
1106
1362
|
...Object.entries(STAT_LABEL).map(([k, name]) => L(` ${fit(name, 10)} ${st[k]}`)),
|
|
@@ -1135,7 +1391,6 @@ function charView(app) {
|
|
|
1135
1391
|
if (hasPetClass(a)) rows.push(L(''), { text: `Pet: ${ch.pet ? ch.pet.name : dim('none out')} ${dim('Enter: the pet page')}`, act: () => a.push(petDialog(a)) });
|
|
1136
1392
|
rows.push(L(''), { text: `Honor ${dim(`${honorRankLine(a)} (:honor)`)}`, act: () => a.push(honorDialog(a)) });
|
|
1137
1393
|
rows.push({ text: `Reputation ${dim(`${a.ch.watchedFaction ? `watching ${ds.faction(a.ch.watchedFaction)?.name ?? ''} ` : ''}(:rep)`)}`, act: () => a.push(reputationDialog(a)) });
|
|
1138
|
-
rows.push({ text: `Settings ${dim('loot, upgrades, disenchanting, junk, gathering (:settings)')}`, act: () => a.push(settingsDialog(a)) });
|
|
1139
1394
|
return rows;
|
|
1140
1395
|
},
|
|
1141
1396
|
};
|
|
@@ -1156,14 +1411,14 @@ export function sellStack(app, slot) {
|
|
|
1156
1411
|
const s = app.g.state.inventory.stacks[slot];
|
|
1157
1412
|
if (!s) return;
|
|
1158
1413
|
const r = app.g.sellItemStack(slot, s.count);
|
|
1159
|
-
if (app.result(r, r.ok ? `Sold ${app.itemLabel(s.item)}${r.qty > 1 ? ` x${r.qty}` : ''} for ${
|
|
1414
|
+
if (app.result(r, r.ok ? `Sold ${app.itemLabel(s.item)}${r.qty > 1 ? ` x${r.qty}` : ''} for ${coinText(r.earned)}.` : '')) noteSale(app, s.item, r.qty, r.earned);
|
|
1160
1415
|
}
|
|
1161
1416
|
|
|
1162
1417
|
/** Sell every junk-filter match, each onto the buyback list. */
|
|
1163
1418
|
export function sellJunk(app) {
|
|
1164
1419
|
const preview = app.g.junkSellPreview();
|
|
1165
1420
|
const r = app.g.sellAllJunk();
|
|
1166
|
-
if (!app.result(r, r.ok ? `Sold ${plural(r.count, 'junk item')} for ${
|
|
1421
|
+
if (!app.result(r, r.ok ? `Sold ${plural(r.count, 'junk item')} for ${coinText(r.earned)}.` : '')) return;
|
|
1167
1422
|
for (const p of preview) noteSale(app, p.entry, p.count, p.unitSell * p.count);
|
|
1168
1423
|
}
|
|
1169
1424
|
|
|
@@ -1179,7 +1434,7 @@ function buyBackAt(app, i) {
|
|
|
1179
1434
|
const r = buyBackDomain(app.g.state.char, app.g.state.inventory, list, i, app.ds.item(e.entry)?.stackable, bagCapacity(app.ch, app.ds));
|
|
1180
1435
|
if (!r.ok) { app.say(red(r.reason ?? 'Cannot buy that back.')); return; }
|
|
1181
1436
|
app.g.state.char = r.char; app.g.state.inventory = r.inventory; app.buyback = r.buyback;
|
|
1182
|
-
app.say(green(`You buy back ${app.itemLabel(e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} for ${
|
|
1437
|
+
app.say(green(`You buy back ${app.itemLabel(e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} for ${coinText(e.refundCopper)}.`));
|
|
1183
1438
|
}
|
|
1184
1439
|
|
|
1185
1440
|
export function vendorDialog(app, vendor) {
|
|
@@ -1195,7 +1450,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1195
1450
|
const comp = row?.data?.comp;
|
|
1196
1451
|
if (!comp) return;
|
|
1197
1452
|
const r = a.g.sellCompanionJunk(comp.id);
|
|
1198
|
-
if (a.result(r, r.ok ? `Sold ${plural(r.count, 'junk item')} of ${comp.name}'s for ${
|
|
1453
|
+
if (a.result(r, r.ok ? `Sold ${plural(r.count, 'junk item')} of ${comp.name}'s for ${coinText(r.earned)}.` : '')) for (const x of r.sold) noteSale(a, x.entry, x.qty, x.refundCopper, comp);
|
|
1199
1454
|
} },
|
|
1200
1455
|
d: { hint: 'destroy', fn: (a, row) => {
|
|
1201
1456
|
if (mode === 'sell' && row?.data) { const i = a.g.state.inventory.stacks.indexOf(row.data); if (i >= 0) destroyStack(a, i); return; }
|
|
@@ -1205,7 +1460,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1205
1460
|
const name = a.itemLabel(row.data.s.item);
|
|
1206
1461
|
a.confirm(`Destroy ${name} from ${comp.name}'s bags?`, () => a.result(a.g.discardCompanionBagStack(comp.id, slot), `Destroyed ${name}.`));
|
|
1207
1462
|
} },
|
|
1208
|
-
...(vendor.canRepair ? { r: { hint: 'repair', fn: (a) => { const r = a.g.repairAll(vendor.entry); a.result(r, r.ok ? (r.spent ? `Repaired for ${
|
|
1463
|
+
...(vendor.canRepair ? { r: { hint: 'repair', fn: (a) => { const r = a.g.repairAll(vendor.entry); a.result(r, r.ok ? (r.spent ? `Repaired for ${coinText(r.spent)}.` : 'Nothing needs repair.') : ''); } } } : {}),
|
|
1209
1464
|
},
|
|
1210
1465
|
onKey(a, k) {
|
|
1211
1466
|
if (k !== 'tab' && k !== 'shift-tab') return false;
|
|
@@ -1217,7 +1472,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1217
1472
|
onClose(a) { /* the vendor stays "open" for :buy / s in bags until you travel */ },
|
|
1218
1473
|
rows(a) {
|
|
1219
1474
|
const tabs = `${bold(VENDOR_MODE_LABEL[mode])} ${dim(`${VENDOR_MODES.indexOf(mode) + 1}/${VENDOR_MODES.length}`)}`;
|
|
1220
|
-
const rows = [{ text: `${bold(vendor.name)} ${dim(`<${vendor.subname ?? 'Vendor'}>`)} ${tabs} ${
|
|
1475
|
+
const rows = [{ text: `${bold(vendor.name)} ${dim(`<${vendor.subname ?? 'Vendor'}>`)} ${tabs} ${coinText(a.ch.gold)}`, header: true }, { text: '', header: true }];
|
|
1221
1476
|
if (mode === 'buy') {
|
|
1222
1477
|
const offers = a.g.vendorOffers(vendor.entry);
|
|
1223
1478
|
const uses = reagentUses(a);
|
|
@@ -1226,13 +1481,13 @@ export function vendorDialog(app, vendor) {
|
|
|
1226
1481
|
const it = a.ds.item(o.itemEntry);
|
|
1227
1482
|
const ok = o.affordable && o.meetsRequirements && o.inStock && o.fits;
|
|
1228
1483
|
const name = `${qualityPaint(it?.quality)(it?.name ?? o.itemEntry)}${o.unitsPerPurchase > 1 ? ` x${o.unitsPerPurchase}` : ''}`;
|
|
1229
|
-
const price = o.honorPrice ? `${o.honorPrice} honor` :
|
|
1484
|
+
const price = o.honorPrice ? `${o.honorPrice} honor` : coinText(o.pricePerPurchase);
|
|
1230
1485
|
const why = !o.meetsRequirements ? red(` ${o.requirementReason ?? 'requirements not met'}`) : !o.affordable ? red(' too expensive') : !o.inStock ? red(' out of stock') : !o.fits ? red(' bags full') : '';
|
|
1231
1486
|
const usedBy = uses.get(o.itemEntry);
|
|
1232
1487
|
rows.push({
|
|
1233
1488
|
text: `${ok ? name : dim(name)} ${yellow(price)}${o.stockRemaining != null ? dim(` ${o.stockRemaining} left`) : ''}${it?.requiredLevel > a.ch.level ? red(` L${it.requiredLevel}`) : ''}${usedBy ? cyan(' reagent') : ''}${why}`, data: o,
|
|
1234
1489
|
icon: it?.icon, sub: dim([it?.typeName, it?.slot].filter(Boolean).join(' · ')),
|
|
1235
|
-
detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${
|
|
1490
|
+
detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${coinText(o.basePricePerPurchase)} before reputation)`)] : []),
|
|
1236
1491
|
...(usedBy ? ['', cyan('Your party crafts with it'), ...usedBy.slice(0, 6).map((n) => ` ${n}`), ...(usedBy.length > 6 ? [dim(` and ${usedBy.length - 6} more`)] : []), dim('Crafting buys it for you when you run short.')] : [])]),
|
|
1237
1492
|
act: () => buy(a, o, 1),
|
|
1238
1493
|
});
|
|
@@ -1240,13 +1495,13 @@ export function vendorDialog(app, vendor) {
|
|
|
1240
1495
|
} else if (mode === 'sell') {
|
|
1241
1496
|
const st = a.g.state.inventory.stacks;
|
|
1242
1497
|
const junk = a.g.junkSellPreview();
|
|
1243
|
-
if (junk.length) rows.push({ text: dim(`j sells ${plural(junk.reduce((n, p) => n + p.count, 0), 'junk item')} for ${
|
|
1498
|
+
if (junk.length) rows.push({ text: dim(`j sells ${plural(junk.reduce((n, p) => n + p.count, 0), 'junk item')} for ${coinText(junk.reduce((n, p) => n + p.unitSell * p.count, 0))}`), header: true });
|
|
1244
1499
|
if (!st.length) rows.push({ text: dim('Your bags are empty.'), header: true });
|
|
1245
1500
|
st.forEach((s, slot) => {
|
|
1246
1501
|
const it = a.ds.item(s.item);
|
|
1247
1502
|
const total = (it?.sellPrice ?? 0) * s.count;
|
|
1248
1503
|
rows.push({
|
|
1249
|
-
text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${total ?
|
|
1504
|
+
text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${total ? coinText(total) : dim('no value')}`, data: s,
|
|
1250
1505
|
icon: it?.icon, sub: dim(it?.typeName ?? ''),
|
|
1251
1506
|
detail: () => itemLines(a, it, ['', dim('Enter: sell the stack (you can buy it back on the Buyback tab) d: destroy')]),
|
|
1252
1507
|
act: () => sellStack(a, slot),
|
|
@@ -1258,8 +1513,8 @@ export function vendorDialog(app, vendor) {
|
|
|
1258
1513
|
list.forEach((e, i) => {
|
|
1259
1514
|
const it = a.ds.item(e.entry);
|
|
1260
1515
|
rows.push({
|
|
1261
|
-
text: `${qualityPaint(it?.quality)(it?.name ?? e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} ${
|
|
1262
|
-
detail: () => itemLines(a, it, ['', `Buy back for ${
|
|
1516
|
+
text: `${qualityPaint(it?.quality)(it?.name ?? e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} ${coinText(e.refundCopper)}${e.ownerName ? dim(` back to ${e.ownerName}`) : ''}`,
|
|
1517
|
+
detail: () => itemLines(a, it, ['', `Buy back for ${coinText(e.refundCopper)}`]),
|
|
1263
1518
|
act: () => buyBackAt(a, i),
|
|
1264
1519
|
});
|
|
1265
1520
|
});
|
|
@@ -1274,9 +1529,9 @@ export function vendorDialog(app, vendor) {
|
|
|
1274
1529
|
bags.forEach((s, slot) => {
|
|
1275
1530
|
const it = a.ds.item(s.item);
|
|
1276
1531
|
rows.push({
|
|
1277
|
-
text: ` ${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${it?.sellPrice ?
|
|
1532
|
+
text: ` ${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${it?.sellPrice ? coinText(it.sellPrice * s.count) : dim('no value')}`, data: { comp, s },
|
|
1278
1533
|
detail: () => itemLines(a, it, ['', dim(`Enter: sell it out of ${comp.name}'s bags j: sell their junk d: destroy`)]),
|
|
1279
|
-
act: () => { const r = a.g.sellCompanionItemStack(comp.id, slot); if (a.result(r, r.ok ? `Sold ${comp.name}'s ${it?.name}${r.qty > 1 ? ` x${r.qty}` : ''} for ${
|
|
1534
|
+
act: () => { const r = a.g.sellCompanionItemStack(comp.id, slot); if (a.result(r, r.ok ? `Sold ${comp.name}'s ${it?.name}${r.qty > 1 ? ` x${r.qty}` : ''} for ${coinText(r.earned)}.` : '')) noteSale(a, s.item, r.qty, r.earned, comp); },
|
|
1280
1535
|
});
|
|
1281
1536
|
});
|
|
1282
1537
|
}
|
|
@@ -1289,37 +1544,71 @@ export function vendorDialog(app, vendor) {
|
|
|
1289
1544
|
|
|
1290
1545
|
function buy(app, offer, n) {
|
|
1291
1546
|
const r = app.g.buyItem(app.vendor.entry, offer.itemEntry, n);
|
|
1292
|
-
app.result(r, r.ok ? `Bought ${app.itemLabel(offer.itemEntry)}${n > 1 ? ` x${n}` : ''} for ${
|
|
1547
|
+
app.result(r, r.ok ? `Bought ${app.itemLabel(offer.itemEntry)}${n > 1 ? ` x${n}` : ''} for ${coinText(r.spent)}.` : '');
|
|
1293
1548
|
}
|
|
1294
1549
|
|
|
1295
1550
|
export function trainerDialog(app, trainer) {
|
|
1296
1551
|
const known = () => new Set(app.g.state.spellbook.known);
|
|
1297
1552
|
// Weapon masters list every class's weapons; hide the rows the class/race gate would refuse.
|
|
1298
1553
|
const fits = (ts) => learnGrantIds(ts.spell, app.ds).every((g) => isSpellFitByClassAndRace(g, app.ch.race, app.ch.cls, app.ds.combatSkillMeta(), Math.max(app.ch.level, ts.reqLevel)));
|
|
1554
|
+
// Higher ranks of a talent ability (Ice Barrier 2+) are sold here, but rank 1 only comes from the
|
|
1555
|
+
// talent point, so until it is known the trainer refuses them. Keyed by the chain's first rank.
|
|
1556
|
+
const talentOf = new Map();
|
|
1557
|
+
for (const tree of app.ds.talentTrees(app.ch.cls) ?? []) for (const t of tree.talents ?? []) for (const r of t.ranks ?? []) {
|
|
1558
|
+
for (const id of [r.spellId, ...(r.effects ?? []).filter((e) => e.kind === 'grant_spell').map((e) => e.spellId)]) if (id) talentOf.set(id, { t, tree });
|
|
1559
|
+
}
|
|
1560
|
+
const needsTalent = (ts) => {
|
|
1561
|
+
for (const g of learnGrantIds(ts.spell, app.ds)) {
|
|
1562
|
+
const first = app.ds.spellChain(g)?.first ?? g, hit = talentOf.get(first);
|
|
1563
|
+
if (hit && !knows(app.g.state.spellbook, first)) return hit;
|
|
1564
|
+
}
|
|
1565
|
+
return null;
|
|
1566
|
+
};
|
|
1299
1567
|
const learnable = () => trainer.spells
|
|
1300
|
-
.filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts))
|
|
1568
|
+
.filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts) && !needsTalent(ts))
|
|
1301
1569
|
.map((ts) => ({ ts, sp: app.ds.spell(ts.spell) })).filter((x) => x.sp)
|
|
1302
1570
|
.sort((x, y) => x.ts.reqLevel - y.ts.reqLevel || x.sp.name.localeCompare(y.sp.name));
|
|
1303
1571
|
return {
|
|
1304
1572
|
id: `trainer-${trainer.entry}`, title: trainer.name, hint: 'Enter: learn',
|
|
1305
1573
|
keys: { A: { hint: 'learn all', fn: (a) => { let n = 0; for (const { sp } of learnable()) if (a.g.learnFromTrainer(trainer.entry, sp.id).ok) { n++; a.say(green(`You have learned ${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''}.`)); } if (!n) a.say('Nothing learned.'); } } },
|
|
1306
1574
|
rows(a) {
|
|
1307
|
-
const rows = [{ text: `${bold(trainer.name)} ${dim(`<${trainer.subname ?? `${className(a.ch.cls)} Trainer`}>`)} ${
|
|
1575
|
+
const rows = [{ text: `${bold(trainer.name)} ${dim(`<${trainer.subname ?? `${className(a.ch.cls)} Trainer`}>`)} ${coinText(a.ch.gold)}`, header: true }, { text: '', header: true }, { text: 'Available now', header: true }];
|
|
1308
1576
|
const now = learnable();
|
|
1309
1577
|
if (!now.length) rows.push({ text: dim(' Nothing new at your level.'), header: true });
|
|
1310
1578
|
for (const { sp, ts } of now) {
|
|
1311
1579
|
const can = ts.cost <= a.ch.gold;
|
|
1312
1580
|
rows.push({
|
|
1313
|
-
text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${
|
|
1581
|
+
text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${coinText(ts.cost, 'free')}`, data: sp,
|
|
1314
1582
|
icon: sp.icon, sub: dim(`level ${ts.reqLevel}${sp.cost ? ` ${sp.cost} ${sp.powerType}` : ''}`),
|
|
1315
|
-
detail: () => spellLines(a, sp, ['', `Cost: ${
|
|
1583
|
+
detail: () => spellLines(a, sp, ['', `Cost: ${coinText(ts.cost, 'free')}`]),
|
|
1316
1584
|
act: () => a.result(a.g.learnFromTrainer(trainer.entry, sp.id), `You have learned ${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''}.`),
|
|
1317
1585
|
});
|
|
1318
1586
|
}
|
|
1319
|
-
const later = trainer.spells.filter((ts) => ts.reqLevel > a.ch.level && !known().has(ts.spell) && fits(ts)).map((ts) => ({ ts, sp: a.ds.spell(ts.spell) })).filter((x) => x.sp).sort((x, y) => x.ts.reqLevel - y.ts.reqLevel).slice(0, 12);
|
|
1587
|
+
const later = trainer.spells.filter((ts) => ts.reqLevel > a.ch.level && !known().has(ts.spell) && fits(ts) && !needsTalent(ts)).map((ts) => ({ ts, sp: a.ds.spell(ts.spell) })).filter((x) => x.sp).sort((x, y) => x.ts.reqLevel - y.ts.reqLevel).slice(0, 12);
|
|
1320
1588
|
if (later.length) {
|
|
1321
1589
|
rows.push({ text: '', header: true }, { text: 'Coming up', header: true });
|
|
1322
|
-
for (const { sp, ts } of later) rows.push({ text: dim(`${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''} level ${ts.reqLevel} ${
|
|
1590
|
+
for (const { sp, ts } of later) rows.push({ text: dim(`${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''} level ${ts.reqLevel} ${coinText(ts.cost)}`), header: true });
|
|
1591
|
+
}
|
|
1592
|
+
// One row per talent, not per rank: the ranks are all locked behind the same point.
|
|
1593
|
+
const gated = new Map();
|
|
1594
|
+
for (const ts of trainer.spells) {
|
|
1595
|
+
const hit = !known().has(ts.spell) && fits(ts) && needsTalent(ts), sp = hit && a.ds.spell(ts.spell);
|
|
1596
|
+
if (!sp) continue;
|
|
1597
|
+
if (!gated.has(hit.t.id)) gated.set(hit.t.id, { ...hit, ranks: [] });
|
|
1598
|
+
gated.get(hit.t.id).ranks.push({ sp, ts });
|
|
1599
|
+
}
|
|
1600
|
+
if (gated.size) {
|
|
1601
|
+
rows.push({ text: '', header: true }, { text: 'Needs a talent', header: true });
|
|
1602
|
+
for (const { t, tree, ranks } of gated.values()) {
|
|
1603
|
+
ranks.sort((x, y) => x.ts.reqLevel - y.ts.reqLevel);
|
|
1604
|
+
const sp = ranks[0].sp, why = `Rank 1 comes from the ${t.name} talent in the ${tree.name} tree. Until you take it, the trainer cannot teach the higher ranks.`;
|
|
1605
|
+
rows.push({
|
|
1606
|
+
text: dim(`${sp.name} ${tree.name} talent, not taken`), data: sp, icon: sp.icon,
|
|
1607
|
+
sub: dim(ranks.map(({ sp: r, ts }) => `R${r.rank ?? '?'} lv${ts.reqLevel}`).join(' ')),
|
|
1608
|
+
detail: () => spellLines(a, sp, ['', yellow(why)]),
|
|
1609
|
+
act: () => a.say(why),
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1323
1612
|
}
|
|
1324
1613
|
return rows;
|
|
1325
1614
|
},
|