azeroth-shell 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -10
- package/bin/azsh.mjs +15 -1
- package/package.json +5 -2
- package/src/cli/hdArt.mjs +1 -1
- package/src/cli/iconArt.mjs +11 -1
- package/src/cli/modelArt.mjs +7 -4
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/repl.mjs +16 -0
- package/src/cli/sixel.mjs +26 -14
- package/src/cli/text.mjs +16 -0
- package/src/cli/tui.mjs +33 -9
- package/src/cli/tuiAutopilot.mjs +17 -8
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiCheck.mjs +276 -15
- package/src/cli/tuiCity.mjs +31 -31
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +189 -29
- package/src/cli/tuiCreate.mjs +7 -0
- package/src/cli/tuiDungeon.mjs +56 -20
- package/src/cli/tuiFight.mjs +63 -21
- package/src/cli/tuiFightPanes.mjs +15 -6
- package/src/cli/tuiFloor.mjs +20 -8
- package/src/cli/tuiGear.mjs +9 -10
- package/src/cli/tuiLoot.mjs +5 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +6 -5
- package/src/cli/tuiPartyStage.mjs +36 -7
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +23 -23
- package/src/cli/tuiSaves.mjs +14 -0
- package/src/cli/tuiSearch.mjs +4 -5
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +72 -16
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +371 -84
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +88 -5
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +36 -36
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- package/src/domain/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/engine/combat/pickAction.ts +3494 -3487
- package/src/engine/combat/sessionChase.ts +12 -1
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/types.ts +4 -0
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,14 +20,17 @@ 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';
|
|
@@ -37,13 +42,14 @@ 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);
|
|
@@ -497,7 +585,7 @@ function bagsView(app) {
|
|
|
497
585
|
rows(a) {
|
|
498
586
|
const st = a.g.state.inventory.stacks;
|
|
499
587
|
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 ${
|
|
588
|
+
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
589
|
st.forEach((s, i) => {
|
|
502
590
|
const it = a.ds.item(s.item);
|
|
503
591
|
const name = qualityPaint(it?.quality)(it?.name ?? `item ${s.item}`);
|
|
@@ -505,7 +593,7 @@ function bagsView(app) {
|
|
|
505
593
|
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
594
|
rows.push({
|
|
507
595
|
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 ${
|
|
596
|
+
icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${coinText(it.sellPrice * s.count)}` : ''}`),
|
|
509
597
|
detail: () => itemLines(a, it, [
|
|
510
598
|
'', dim(`Enter: ${verb ? verb.toLowerCase() : 'more'} m: more${s.count > 1 ? ' /: split' : ''} [ ]: move d: destroy`),
|
|
511
599
|
...(a.vendor ? [dim(`s: sell to ${a.vendor.name}`)] : []),
|
|
@@ -556,10 +644,13 @@ function compareLines(app, it, worn) {
|
|
|
556
644
|
return out;
|
|
557
645
|
}
|
|
558
646
|
|
|
559
|
-
|
|
647
|
+
/** The Character tab: the paperdoll (your gear around your body), your numbers under the tooltip, s for the rest. */
|
|
648
|
+
function characterView(app) {
|
|
560
649
|
return {
|
|
561
|
-
id: 'gear', title: '
|
|
650
|
+
id: 'gear', title: 'Character', hint: 'Enter: change what you wear',
|
|
651
|
+
detailFoot: (a, w) => statPanel(a, w),
|
|
562
652
|
keys: {
|
|
653
|
+
s: { hint: 'stats', fn: (a) => a.push(characterDetailsDialog(a)) },
|
|
563
654
|
u: { hint: 'equip all upgrades', fn: (a) => {
|
|
564
655
|
if (!upgradeList(a).length) { a.flashMsg("Nothing in your bags or your companions' beats what you wear."); return; }
|
|
565
656
|
// Best first, re-scored after each piece (two rings, a two-hander). Works in suggest mode too,
|
|
@@ -581,12 +672,13 @@ function gearView(app) {
|
|
|
581
672
|
o: { hint: 'outfits', fn: (a) => a.push(outfitterDialog(a)) },
|
|
582
673
|
},
|
|
583
674
|
// 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),
|
|
675
|
+
grid: (a, rows, w, h) => paperdollGrid(a, rows, w, h, (cols, lines) => playerArt(a, cols, lines)?.lines ?? null, characterTitle(a)),
|
|
585
676
|
rows(a) {
|
|
586
677
|
const rows = [];
|
|
587
678
|
const ups = upgradeList(a);
|
|
588
679
|
const extras = gearExtraRows(a);
|
|
589
680
|
const sheet = paperdollPlan(a, extras.filter((r) => !r.header).length);
|
|
681
|
+
if (!sheet) rows.push({ text: `${characterTitle(a)} ${dim('s: stats, skills, auras, reputation')}`, header: true, plain: true }, { text: '', header: true });
|
|
590
682
|
if (ups.length && !sheet) {
|
|
591
683
|
rows.push({ text: `${green('▲')} Better gear for you (${ups.length}) ${dim('Enter: put on u: put on all')}`, header: true });
|
|
592
684
|
for (const u of ups) {
|
|
@@ -642,8 +734,8 @@ function slotDialog(app, slot) {
|
|
|
642
734
|
const low = it.requiredLevel > ch.level;
|
|
643
735
|
const name = qualityPaint(it.quality)(it.name);
|
|
644
736
|
return {
|
|
645
|
-
text:
|
|
646
|
-
data: { s, it },
|
|
737
|
+
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}`) : ''}`,
|
|
738
|
+
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
739
|
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
740
|
act: () => {
|
|
649
741
|
if (from === 'bank' && !a.result(a.g.bankWithdraw(s.item, 1), '')) return;
|
|
@@ -672,15 +764,63 @@ function slotDialog(app, slot) {
|
|
|
672
764
|
};
|
|
673
765
|
}
|
|
674
766
|
|
|
767
|
+
/**
|
|
768
|
+
* The spellbook's tabs, as the client draws them: General, then one per talent tree. A spell goes on the tab
|
|
769
|
+
* of its class skill line (SkillLineAbility, baked as combatSkillMeta.spellAbilities); the line is matched to
|
|
770
|
+
* its tree by name, since the trees carry no skill id ("Shadow" is Shadow Magic, "Elemental" is Elemental
|
|
771
|
+
* Combat). Everything else (Attack, racials, Poisons, Beast Training) is General.
|
|
772
|
+
*/
|
|
773
|
+
function spellbookTabs(ch, ds) {
|
|
774
|
+
const meta = ds.combatSkillMeta(), bit = 1 << (ch.cls - 1);
|
|
775
|
+
const trees = ds.talentTrees(ch.cls).map((t) => t.name);
|
|
776
|
+
const treeOfSkill = (skillId) => {
|
|
777
|
+
if (meta?.categories?.[skillId] !== 7) return -1;
|
|
778
|
+
const n = (meta.skillNames?.[skillId] ?? '').toLowerCase();
|
|
779
|
+
return trees.findIndex((t) => n === t.toLowerCase() || n.startsWith(`${t.toLowerCase()} `));
|
|
780
|
+
};
|
|
781
|
+
const tabOf = (sp) => {
|
|
782
|
+
for (const r of meta?.spellAbilities?.[sp.id] ?? []) {
|
|
783
|
+
if (r.classMask && !(r.classMask & bit)) continue;
|
|
784
|
+
const k = treeOfSkill(r.skillId);
|
|
785
|
+
if (k >= 0) return k + 1;
|
|
786
|
+
}
|
|
787
|
+
return 0;
|
|
788
|
+
};
|
|
789
|
+
return { names: ['General', ...trees], tabOf };
|
|
790
|
+
}
|
|
791
|
+
|
|
675
792
|
function spellsView(app) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
793
|
+
// Until a tab is picked the book opens on the first one with spells (a warrior's General can be empty).
|
|
794
|
+
let book = null;
|
|
795
|
+
const turn = (a, d) => { a.rowsOf(view); const n = spellbookTabs(a.ch, a.ds).names.length; book = (book + d + n) % n; view.scroll = 0; };
|
|
796
|
+
const view = {
|
|
797
|
+
id: 'spells', title: 'Spellbook', hint: 'Enter: cast [ ]: tab',
|
|
798
|
+
keys: {
|
|
799
|
+
b: { hint: 'action bars', fn: (a) => a.push(barsDialog(a, playerBarsOwner)) }, m: { hint: 'macros', fn: (a) => a.push(macrosDialog(a)) },
|
|
800
|
+
'[': { fn: (a) => turn(a, -1) }, ']': { fn: (a) => turn(a, +1) },
|
|
801
|
+
},
|
|
802
|
+
// Left Right on the tab strip turn the book's tabs; anywhere else they still change the window's tabs.
|
|
803
|
+
onKey(a, k, row) {
|
|
804
|
+
if (!row?.data?.strip || (k !== 'left' && k !== 'right')) return false;
|
|
805
|
+
turn(a, k === 'right' ? +1 : -1);
|
|
806
|
+
return true;
|
|
807
|
+
},
|
|
679
808
|
rows(a) {
|
|
680
|
-
const
|
|
809
|
+
const tabs = spellbookTabs(a.ch, a.ds);
|
|
810
|
+
const all = knownUsableSpells(a.ch, a.g.state.spellbook, a.ds);
|
|
811
|
+
const counts = tabs.names.map((_, k) => all.filter((sp) => tabs.tabOf(sp) === k).length);
|
|
812
|
+
book = book == null ? Math.max(0, counts.findIndex((n) => n > 0)) : Math.min(book, tabs.names.length - 1);
|
|
813
|
+
const known = all.filter((sp) => tabs.tabOf(sp) === book).sort((x, y) => x.name.localeCompare(y.name));
|
|
814
|
+
const L = a.layout(), width = (L.detailW ? L.listW : L.W) - 2;
|
|
681
815
|
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
816
|
{ 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
817
|
{ text: '', header: true }];
|
|
818
|
+
head.push({
|
|
819
|
+
text: tabStrip(tabs.names.map((n, k) => `${n} ${counts[k]}`), book, width - 1, view.cursor === head.length), data: { strip: true },
|
|
820
|
+
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')],
|
|
821
|
+
act: (a2) => a2.moveCursor(view, a2.rowsOf(view), +1),
|
|
822
|
+
}, { text: '', header: true });
|
|
823
|
+
if (!known.length) head.push({ text: dim('No spells on this tab yet.'), header: true });
|
|
684
824
|
return [...head, ...known.map((sp) => {
|
|
685
825
|
const cd = a.g.spellCooldownRemaining(sp.id);
|
|
686
826
|
return {
|
|
@@ -692,6 +832,7 @@ function spellsView(app) {
|
|
|
692
832
|
})];
|
|
693
833
|
},
|
|
694
834
|
};
|
|
835
|
+
return view;
|
|
695
836
|
}
|
|
696
837
|
|
|
697
838
|
// ---- rotation ------------------------------------------------------------------------------------
|
|
@@ -781,8 +922,10 @@ function profileMenu(app) {
|
|
|
781
922
|
|
|
782
923
|
function rotationView(app) {
|
|
783
924
|
const rebuild = (a) => a.confirm('Rebuild the recommended rotation? The edits are lost.', () => { rotOwner(a).reset(); a.say(green('Rotation rebuilt.')); });
|
|
784
|
-
|
|
925
|
+
const view = {
|
|
785
926
|
id: 'rotation', title: 'Rotation', hint: 'Enter: open',
|
|
927
|
+
// The phases Enter has opened; all start closed so the tab fits a screen.
|
|
928
|
+
open: new Set(),
|
|
786
929
|
keys: {
|
|
787
930
|
w: { hint: 'whose', fn: (a) => pickRotationOwner(a) },
|
|
788
931
|
p: { hint: 'profiles', fn: (a) => profileMenu(a) },
|
|
@@ -796,6 +939,7 @@ function rotationView(app) {
|
|
|
796
939
|
d: { fn: (a, row) => row?.data?.rule && a.confirm('Delete this rule?', () => deleteRule(a, row.data)) },
|
|
797
940
|
s: { hint: 'spec', fn: (a) => { if (!rotOwner(a).comp) pickSpec(a); } },
|
|
798
941
|
r: { hint: 'rebuild', fn: rebuild },
|
|
942
|
+
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
943
|
},
|
|
800
944
|
rows(a) {
|
|
801
945
|
const g = a.g, o = rotOwner(a), rot = o.get(), names = rotNames(a);
|
|
@@ -805,18 +949,28 @@ function rotationView(app) {
|
|
|
805
949
|
{ 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
950
|
...((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
951
|
{ 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 })),
|
|
952
|
+
...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
953
|
];
|
|
810
954
|
for (const phase of PHASES) {
|
|
811
955
|
const rules = rot.phases[phase] ?? [];
|
|
812
956
|
if (!rules.length && phase === 'prebuff') continue;
|
|
813
|
-
|
|
957
|
+
// A phase is one row until Enter opens it; closed, it previews the first few spells it casts.
|
|
958
|
+
const open = view.open.has(phase), on = rules.filter((r) => r.enabled);
|
|
959
|
+
const [title, gloss = ''] = PHASE_LABEL[phase].split(/ (?=\()/);
|
|
960
|
+
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 ? ', …' : '');
|
|
961
|
+
rows.push({ text: '', header: true }, {
|
|
962
|
+
text: `${open ? '▾' : '▸'} ${bold(title)} ${dim(gloss)} ${cyan(plural(rules.length, 'rule'))}${!open && preview ? dim(` ${preview}`) : ''}`,
|
|
963
|
+
data: { phase, toggle: true },
|
|
964
|
+
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')],
|
|
965
|
+
act: () => { if (open) view.open.delete(phase); else view.open.add(phase); },
|
|
966
|
+
});
|
|
967
|
+
if (!open) continue;
|
|
814
968
|
rules.forEach((rule, index) => {
|
|
815
969
|
const conds = conditionsSentence(rule.conditions, names);
|
|
816
970
|
// With icons a rule is two lines and the condition takes the second; without, it trails the sentence.
|
|
817
971
|
const icon = iconsOn() ? a.ds.spell(rule.action?.spellId ?? rule.before?.spellId)?.icon : undefined;
|
|
818
972
|
const when = conds !== 'always' ? dim(`when ${conds}`) : '';
|
|
819
|
-
const text =
|
|
973
|
+
const text = ` ${rule.enabled ? green('✓') : dim('·')} ${rule.enabled ? ruleSentence(rule, names) : dim(ruleSentence(rule, names))}${!icon && when ? ` ${when}` : ''}`;
|
|
820
974
|
rows.push({
|
|
821
975
|
text, data: { phase, index, rule },
|
|
822
976
|
icon, sub: when,
|
|
@@ -825,7 +979,7 @@ function rotationView(app) {
|
|
|
825
979
|
});
|
|
826
980
|
});
|
|
827
981
|
rows.push({
|
|
828
|
-
text: dim(
|
|
982
|
+
text: dim(` + Add a rule to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}`), data: { phase },
|
|
829
983
|
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
984
|
act: () => addRule(a, phase),
|
|
831
985
|
});
|
|
@@ -844,6 +998,7 @@ function rotationView(app) {
|
|
|
844
998
|
return rows;
|
|
845
999
|
},
|
|
846
1000
|
};
|
|
1001
|
+
return view;
|
|
847
1002
|
}
|
|
848
1003
|
|
|
849
1004
|
/** Enter on a rule: everything you can do with it, in one list. */
|
|
@@ -918,6 +1073,7 @@ function addRule(app, phase, afterIndex) {
|
|
|
918
1073
|
} else if (picked.kind === 'item') {
|
|
919
1074
|
({ action, conditions } = defaultItemRule(app, o, picked.itemEntry));
|
|
920
1075
|
}
|
|
1076
|
+
app.tabs[5].view.open.add(phase);
|
|
921
1077
|
editRotation(app, (rot) => {
|
|
922
1078
|
const rule = { id, action, conditions, enabled: true };
|
|
923
1079
|
const list = rot.phases[phase] ??= [];
|
|
@@ -953,7 +1109,7 @@ function talentsView(app) {
|
|
|
953
1109
|
grid: (a, rows, w, h) => talentGrid(a, rows, w, h),
|
|
954
1110
|
keys: {
|
|
955
1111
|
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 ${
|
|
1112
|
+
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
1113
|
B: { hint: 'builds', fn: (a) => talentBuildsMenu(a) },
|
|
958
1114
|
},
|
|
959
1115
|
rows(a) {
|
|
@@ -1010,27 +1166,40 @@ function talentLinesText(app, t, rank, can) {
|
|
|
1010
1166
|
function travelView(app) {
|
|
1011
1167
|
return {
|
|
1012
1168
|
id: 'travel', title: 'Travel', hint: 'Enter: go',
|
|
1169
|
+
// A row without its own detail (a teleport, a portal) still shows the world map with you on it.
|
|
1170
|
+
detail(a) { return withWorldMap(a, [bold(a.zone()?.name ?? a.ch.zoneId), dim('The world, and where you stand in it.')], {}); },
|
|
1013
1171
|
rows(a) {
|
|
1014
1172
|
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
|
-
|
|
1173
|
+
const rows = [...journeyRows(a), { text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }];
|
|
1174
|
+
// A zone's neighbors are every travel edge out of it: a boat, a zeppelin or the tram is not a walk.
|
|
1175
|
+
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]));
|
|
1176
|
+
const groups = [['On foot', ['ground', 'portal']], ['Boats and zeppelins', ['transport']], ['Deeprun Tram', ['tram']]];
|
|
1177
|
+
for (const [title, kinds] of groups) {
|
|
1178
|
+
const near = (z?.neighbors ?? []).filter((id) => kinds.includes(edgeTo.get(id)?.kind ?? 'ground')).map((id) => [ds.zone(id), edgeTo.get(id)]).filter(([d]) => d);
|
|
1179
|
+
if (!near.length) continue;
|
|
1180
|
+
rows.push({ text: '', header: true }, { text: title, header: true });
|
|
1181
|
+
for (const [d, e] of near) {
|
|
1182
|
+
const line = e?.kind === 'transport' ? ds.transports().find((t) => t.entry === e.transport)?.name : null;
|
|
1183
|
+
rows.push({ text: `${d.name} ${dim(`L${d.levelRange[0]}-${d.levelRange[1]}`)}${line ? dim(` ${line}`) : ''}`, detail: () => travelPreviewLines(a, d), act: () => travelTo(a, d) });
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1017
1186
|
const taxi = g.taxiOptions();
|
|
1018
1187
|
if (taxi.length) {
|
|
1019
1188
|
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)}, ${
|
|
1189
|
+
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
1190
|
}
|
|
1022
1191
|
rows.push({ text: '', header: true }, { text: 'Hearthstone', header: true });
|
|
1023
1192
|
const bind = ch.hearthBind;
|
|
1024
1193
|
const cd = Math.max(0, (ch.hearthCooldownUntil ?? 0) - Date.now());
|
|
1025
1194
|
rows.push({
|
|
1026
1195
|
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')],
|
|
1196
|
+
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
1197
|
act: () => { const r = g.castHearth(); if (a.result(r)) { a.vendor = null; afterDeparture(a, () => `You hearth to ${a.zone()?.name}.`); landOnTab(a); } },
|
|
1029
1198
|
});
|
|
1030
1199
|
const ports = g.classTravelOptions();
|
|
1031
1200
|
if (ports.length) {
|
|
1032
1201
|
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); } } });
|
|
1202
|
+
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
1203
|
}
|
|
1035
1204
|
rows.push(...portalRows(a));
|
|
1036
1205
|
return rows;
|
|
@@ -1065,25 +1234,110 @@ function weaponSkillRows(a, L) {
|
|
|
1065
1234
|
return rows;
|
|
1066
1235
|
}
|
|
1067
1236
|
|
|
1237
|
+
// How tall a worn model stands against the body it replaces, which fills its box. Sprites are cropped to the
|
|
1238
|
+
// model, so a cat drawn into a person's box came out the size of a person. The client's collision heights are
|
|
1239
|
+
// one placeholder for most of these models, so this goes by shape: the druid's animals and Ghost Wolf stand on
|
|
1240
|
+
// four legs, a transform by its creature's type (a Polymorph sheep is a critter, a ninja costume a humanoid).
|
|
1241
|
+
const FORM_HEIGHT = {
|
|
1242
|
+
[SHAPESHIFT_FORM.CAT]: 0.6, [SHAPESHIFT_FORM.BEAR]: 0.7, [SHAPESHIFT_FORM.DIREBEAR]: 0.7,
|
|
1243
|
+
[SHAPESHIFT_FORM.TRAVEL]: 0.65, [SHAPESHIFT_FORM.AQUA]: 0.5, [SHAPESHIFT_FORM.GHOSTWOLF]: 0.6,
|
|
1244
|
+
};
|
|
1245
|
+
const TYPE_HEIGHT = { [CREATURE_TYPE.BEAST]: 0.6, [CREATURE_TYPE.CRITTER]: 0.35 };
|
|
1246
|
+
const typeByDisplay = new WeakMap(); // ds -> displayId -> the creature type of the first creature drawn with it
|
|
1247
|
+
|
|
1248
|
+
function displayType(ds, displayId) {
|
|
1249
|
+
let m = typeByDisplay.get(ds);
|
|
1250
|
+
if (!m) {
|
|
1251
|
+
m = new Map();
|
|
1252
|
+
for (const c of ds.creatures()) if (c.displayId && !m.has(c.displayId)) m.set(c.displayId, c.creatureType);
|
|
1253
|
+
typeByDisplay.set(ds, m);
|
|
1254
|
+
}
|
|
1255
|
+
return m.get(displayId);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* The model `subject` (you, or a companion) wears right now, or null for its own body: { displayId, label,
|
|
1260
|
+
* height }, height the share of the box it stands in (FORM_HEIGHT). In a fight, `view` (its combat view) has
|
|
1261
|
+
* the live form and auras: a shift or a Polymorph lands there, not on the saved character, whose costumes are
|
|
1262
|
+
* still read under them.
|
|
1263
|
+
*/
|
|
1264
|
+
export function wornNow(ds, subject, view) {
|
|
1265
|
+
const worn = wornModel(ds, subject, view);
|
|
1266
|
+
if (!worn) return null;
|
|
1267
|
+
const form = ((view ? view.form : subject.form) ?? 0) | 0;
|
|
1268
|
+
const height = form && worn.displayId === formDisplayId(form, factionOf(ds, subject.race))
|
|
1269
|
+
? FORM_HEIGHT[form] ?? 1
|
|
1270
|
+
: TYPE_HEIGHT[displayType(ds, worn.displayId)] ?? 1;
|
|
1271
|
+
return { ...worn, height };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** A worn model (wornNow) as art in a box `maxCols` x `maxRows`, standing its height in it; null without art. */
|
|
1275
|
+
export function wornArt(worn, maxCols, maxRows) {
|
|
1276
|
+
return worn?.displayId ? creatureArt(worn.displayId, maxCols, Math.max(3, Math.round(maxRows * worn.height))) : null;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function wornModel(ds, subject, view) {
|
|
1280
|
+
const now = Date.now();
|
|
1281
|
+
if (!view) return wornDisplay(subject, ds, now);
|
|
1282
|
+
// Polymorph and Hex bake as crowd control, the model swap left in their raw aura 56 row, which
|
|
1283
|
+
// wornDisplay reads past. Being turned into a sheep is the latest model change, so it wins.
|
|
1284
|
+
for (const d of view.debuffs ?? []) {
|
|
1285
|
+
const spell = d.spellId != null ? ds.spell(d.spellId) : null;
|
|
1286
|
+
const raw = spell?.rawEffects?.find((r) => (r.auraName | 0) === 56 && r.miscValue);
|
|
1287
|
+
const displayId = raw ? ds.creature(raw.miscValue | 0)?.displayId : 0;
|
|
1288
|
+
if (displayId) return { displayId, label: spell.name };
|
|
1289
|
+
}
|
|
1290
|
+
const live = [...(view.buffs ?? []), ...(view.debuffs ?? [])].filter((b) => b.spellId != null).map((b) => ({ spellId: b.spellId }));
|
|
1291
|
+
return wornDisplay({ ...subject, form: view.form ?? 0, activeAuras: [...live, ...(subject.activeAuras ?? [])] }, ds, now);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1068
1294
|
/**
|
|
1069
1295
|
* 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.
|
|
1296
|
+
* you in your gear (paperdoll.mjs), else your bare race and gender body. `view` is your combat view in a
|
|
1297
|
+
* fight (wornNow). `label` names the form when that is what shows; null without art.
|
|
1072
1298
|
*/
|
|
1073
|
-
export function playerArt(app, maxCols, maxRows) {
|
|
1299
|
+
export function playerArt(app, maxCols, maxRows, view = null) {
|
|
1074
1300
|
const ch = app.ch;
|
|
1075
|
-
const worn =
|
|
1076
|
-
const
|
|
1077
|
-
if (
|
|
1301
|
+
const worn = wornNow(app.ds, ch, view);
|
|
1302
|
+
const drawn = wornArt(worn, maxCols, maxRows);
|
|
1303
|
+
if (drawn) return { lines: drawn, label: worn.label };
|
|
1078
1304
|
const geared = paperdollArt(ch, maxCols, maxRows);
|
|
1079
1305
|
if (geared) return { lines: geared, label: null };
|
|
1080
1306
|
const own = creatureArt(nativeDisplayId(ch.race, ch.gender), maxCols, maxRows);
|
|
1081
1307
|
return own ? { lines: own, label: null } : null;
|
|
1082
1308
|
}
|
|
1083
1309
|
|
|
1084
|
-
|
|
1310
|
+
/** Name, level, race and class: the Character tab's title line. */
|
|
1311
|
+
function characterTitle(a) {
|
|
1312
|
+
const ch = a.ch;
|
|
1313
|
+
return `${bold(ch.name)} ${dim(`level ${ch.level} ${raceName(ch.race)} ${className(ch.cls)}`)}`;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/** Your numbers at the foot of the Character tab's tooltip pane: health, power, experience, money, then the
|
|
1317
|
+
* attributes beside the combat ratings, the way the client's stat frames sit under the paperdoll. */
|
|
1318
|
+
function statPanel(a, w) {
|
|
1319
|
+
const ch = a.ch, ds = a.ds;
|
|
1320
|
+
const st = effectiveStats(ch, ds);
|
|
1321
|
+
const pt = powerType(ch);
|
|
1322
|
+
const pct = (x) => `${((x ?? 0) * 100).toFixed(2)}%`;
|
|
1323
|
+
const combat = [
|
|
1324
|
+
['Armor', effectiveArmor(ch, ds)], ['Attack pwr', attackPower(ch, ds)], ['Crit', pct(critChanceDisplay(ch, ds))],
|
|
1325
|
+
['Hit', pct(hitChance(ch, ds))], ['Dodge', pct(dodgeChanceDisplay(ch, ds))],
|
|
1326
|
+
];
|
|
1327
|
+
const attrs = Object.entries(STAT_LABEL).map(([k, name]) => `${fit(name, 10)}${String(st[k]).padStart(5)}`);
|
|
1328
|
+
return [
|
|
1329
|
+
dim('─'.repeat(Math.max(0, w))),
|
|
1330
|
+
`Health ${ch.health}/${maxHealth(ch, ds)} ${pt[0].toUpperCase() + pt.slice(1)} ${ch.power}/${maxPower(ch, ds)}`,
|
|
1331
|
+
`XP ${ch.xp}/${ds.xpToNext(ch.level)}${ch.restedXp ? dim(` (${ch.restedXp} rested)`) : ''} ${coinText(ch.gold)}`,
|
|
1332
|
+
'',
|
|
1333
|
+
...attrs.map((t, i) => `${t} ${fit(combat[i][0], 10)} ${String(combat[i][1]).padStart(7)}`),
|
|
1334
|
+
];
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** s on the Character tab: the whole sheet in words: stats, weapon skills, professions, auras, home, pet, honor, reputation. */
|
|
1338
|
+
function characterDetailsDialog(app) {
|
|
1085
1339
|
return {
|
|
1086
|
-
id: 'char', title: '
|
|
1340
|
+
id: 'char', title: 'Stats', hint: 'Esc: back',
|
|
1087
1341
|
/** The detail pane holds your character: the body of your race and gender, or the form you are in. */
|
|
1088
1342
|
detail(a) {
|
|
1089
1343
|
const L = a.layout(), ch = a.ch;
|
|
@@ -1100,7 +1354,7 @@ function charView(app) {
|
|
|
1100
1354
|
L(`${bold(ch.name)} level ${ch.level} ${className(ch.cls)}`),
|
|
1101
1355
|
L(''),
|
|
1102
1356
|
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)`) : ''} ${
|
|
1357
|
+
L(`Experience ${ch.xp}/${ds.xpToNext(ch.level)}${ch.restedXp ? dim(` (${ch.restedXp} rested)`) : ''} ${coinText(ch.gold)}`),
|
|
1104
1358
|
L(''),
|
|
1105
1359
|
L(cyan('Attributes')),
|
|
1106
1360
|
...Object.entries(STAT_LABEL).map(([k, name]) => L(` ${fit(name, 10)} ${st[k]}`)),
|
|
@@ -1135,7 +1389,6 @@ function charView(app) {
|
|
|
1135
1389
|
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
1390
|
rows.push(L(''), { text: `Honor ${dim(`${honorRankLine(a)} (:honor)`)}`, act: () => a.push(honorDialog(a)) });
|
|
1137
1391
|
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
1392
|
return rows;
|
|
1140
1393
|
},
|
|
1141
1394
|
};
|
|
@@ -1156,14 +1409,14 @@ export function sellStack(app, slot) {
|
|
|
1156
1409
|
const s = app.g.state.inventory.stacks[slot];
|
|
1157
1410
|
if (!s) return;
|
|
1158
1411
|
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 ${
|
|
1412
|
+
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
1413
|
}
|
|
1161
1414
|
|
|
1162
1415
|
/** Sell every junk-filter match, each onto the buyback list. */
|
|
1163
1416
|
export function sellJunk(app) {
|
|
1164
1417
|
const preview = app.g.junkSellPreview();
|
|
1165
1418
|
const r = app.g.sellAllJunk();
|
|
1166
|
-
if (!app.result(r, r.ok ? `Sold ${plural(r.count, 'junk item')} for ${
|
|
1419
|
+
if (!app.result(r, r.ok ? `Sold ${plural(r.count, 'junk item')} for ${coinText(r.earned)}.` : '')) return;
|
|
1167
1420
|
for (const p of preview) noteSale(app, p.entry, p.count, p.unitSell * p.count);
|
|
1168
1421
|
}
|
|
1169
1422
|
|
|
@@ -1179,7 +1432,7 @@ function buyBackAt(app, i) {
|
|
|
1179
1432
|
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
1433
|
if (!r.ok) { app.say(red(r.reason ?? 'Cannot buy that back.')); return; }
|
|
1181
1434
|
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 ${
|
|
1435
|
+
app.say(green(`You buy back ${app.itemLabel(e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} for ${coinText(e.refundCopper)}.`));
|
|
1183
1436
|
}
|
|
1184
1437
|
|
|
1185
1438
|
export function vendorDialog(app, vendor) {
|
|
@@ -1195,7 +1448,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1195
1448
|
const comp = row?.data?.comp;
|
|
1196
1449
|
if (!comp) return;
|
|
1197
1450
|
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 ${
|
|
1451
|
+
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
1452
|
} },
|
|
1200
1453
|
d: { hint: 'destroy', fn: (a, row) => {
|
|
1201
1454
|
if (mode === 'sell' && row?.data) { const i = a.g.state.inventory.stacks.indexOf(row.data); if (i >= 0) destroyStack(a, i); return; }
|
|
@@ -1205,7 +1458,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1205
1458
|
const name = a.itemLabel(row.data.s.item);
|
|
1206
1459
|
a.confirm(`Destroy ${name} from ${comp.name}'s bags?`, () => a.result(a.g.discardCompanionBagStack(comp.id, slot), `Destroyed ${name}.`));
|
|
1207
1460
|
} },
|
|
1208
|
-
...(vendor.canRepair ? { r: { hint: 'repair', fn: (a) => { const r = a.g.repairAll(vendor.entry); a.result(r, r.ok ? (r.spent ? `Repaired for ${
|
|
1461
|
+
...(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
1462
|
},
|
|
1210
1463
|
onKey(a, k) {
|
|
1211
1464
|
if (k !== 'tab' && k !== 'shift-tab') return false;
|
|
@@ -1217,7 +1470,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1217
1470
|
onClose(a) { /* the vendor stays "open" for :buy / s in bags until you travel */ },
|
|
1218
1471
|
rows(a) {
|
|
1219
1472
|
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} ${
|
|
1473
|
+
const rows = [{ text: `${bold(vendor.name)} ${dim(`<${vendor.subname ?? 'Vendor'}>`)} ${tabs} ${coinText(a.ch.gold)}`, header: true }, { text: '', header: true }];
|
|
1221
1474
|
if (mode === 'buy') {
|
|
1222
1475
|
const offers = a.g.vendorOffers(vendor.entry);
|
|
1223
1476
|
const uses = reagentUses(a);
|
|
@@ -1226,13 +1479,13 @@ export function vendorDialog(app, vendor) {
|
|
|
1226
1479
|
const it = a.ds.item(o.itemEntry);
|
|
1227
1480
|
const ok = o.affordable && o.meetsRequirements && o.inStock && o.fits;
|
|
1228
1481
|
const name = `${qualityPaint(it?.quality)(it?.name ?? o.itemEntry)}${o.unitsPerPurchase > 1 ? ` x${o.unitsPerPurchase}` : ''}`;
|
|
1229
|
-
const price = o.honorPrice ? `${o.honorPrice} honor` :
|
|
1482
|
+
const price = o.honorPrice ? `${o.honorPrice} honor` : coinText(o.pricePerPurchase);
|
|
1230
1483
|
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
1484
|
const usedBy = uses.get(o.itemEntry);
|
|
1232
1485
|
rows.push({
|
|
1233
1486
|
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
1487
|
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(`(${
|
|
1488
|
+
detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${coinText(o.basePricePerPurchase)} before reputation)`)] : []),
|
|
1236
1489
|
...(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
1490
|
act: () => buy(a, o, 1),
|
|
1238
1491
|
});
|
|
@@ -1240,13 +1493,13 @@ export function vendorDialog(app, vendor) {
|
|
|
1240
1493
|
} else if (mode === 'sell') {
|
|
1241
1494
|
const st = a.g.state.inventory.stacks;
|
|
1242
1495
|
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 ${
|
|
1496
|
+
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
1497
|
if (!st.length) rows.push({ text: dim('Your bags are empty.'), header: true });
|
|
1245
1498
|
st.forEach((s, slot) => {
|
|
1246
1499
|
const it = a.ds.item(s.item);
|
|
1247
1500
|
const total = (it?.sellPrice ?? 0) * s.count;
|
|
1248
1501
|
rows.push({
|
|
1249
|
-
text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${total ?
|
|
1502
|
+
text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${total ? coinText(total) : dim('no value')}`, data: s,
|
|
1250
1503
|
icon: it?.icon, sub: dim(it?.typeName ?? ''),
|
|
1251
1504
|
detail: () => itemLines(a, it, ['', dim('Enter: sell the stack (you can buy it back on the Buyback tab) d: destroy')]),
|
|
1252
1505
|
act: () => sellStack(a, slot),
|
|
@@ -1258,8 +1511,8 @@ export function vendorDialog(app, vendor) {
|
|
|
1258
1511
|
list.forEach((e, i) => {
|
|
1259
1512
|
const it = a.ds.item(e.entry);
|
|
1260
1513
|
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 ${
|
|
1514
|
+
text: `${qualityPaint(it?.quality)(it?.name ?? e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} ${coinText(e.refundCopper)}${e.ownerName ? dim(` back to ${e.ownerName}`) : ''}`,
|
|
1515
|
+
detail: () => itemLines(a, it, ['', `Buy back for ${coinText(e.refundCopper)}`]),
|
|
1263
1516
|
act: () => buyBackAt(a, i),
|
|
1264
1517
|
});
|
|
1265
1518
|
});
|
|
@@ -1274,9 +1527,9 @@ export function vendorDialog(app, vendor) {
|
|
|
1274
1527
|
bags.forEach((s, slot) => {
|
|
1275
1528
|
const it = a.ds.item(s.item);
|
|
1276
1529
|
rows.push({
|
|
1277
|
-
text: ` ${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${it?.sellPrice ?
|
|
1530
|
+
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
1531
|
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 ${
|
|
1532
|
+
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
1533
|
});
|
|
1281
1534
|
});
|
|
1282
1535
|
}
|
|
@@ -1289,37 +1542,71 @@ export function vendorDialog(app, vendor) {
|
|
|
1289
1542
|
|
|
1290
1543
|
function buy(app, offer, n) {
|
|
1291
1544
|
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 ${
|
|
1545
|
+
app.result(r, r.ok ? `Bought ${app.itemLabel(offer.itemEntry)}${n > 1 ? ` x${n}` : ''} for ${coinText(r.spent)}.` : '');
|
|
1293
1546
|
}
|
|
1294
1547
|
|
|
1295
1548
|
export function trainerDialog(app, trainer) {
|
|
1296
1549
|
const known = () => new Set(app.g.state.spellbook.known);
|
|
1297
1550
|
// Weapon masters list every class's weapons; hide the rows the class/race gate would refuse.
|
|
1298
1551
|
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)));
|
|
1552
|
+
// Higher ranks of a talent ability (Ice Barrier 2+) are sold here, but rank 1 only comes from the
|
|
1553
|
+
// talent point, so until it is known the trainer refuses them. Keyed by the chain's first rank.
|
|
1554
|
+
const talentOf = new Map();
|
|
1555
|
+
for (const tree of app.ds.talentTrees(app.ch.cls) ?? []) for (const t of tree.talents ?? []) for (const r of t.ranks ?? []) {
|
|
1556
|
+
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 });
|
|
1557
|
+
}
|
|
1558
|
+
const needsTalent = (ts) => {
|
|
1559
|
+
for (const g of learnGrantIds(ts.spell, app.ds)) {
|
|
1560
|
+
const first = app.ds.spellChain(g)?.first ?? g, hit = talentOf.get(first);
|
|
1561
|
+
if (hit && !knows(app.g.state.spellbook, first)) return hit;
|
|
1562
|
+
}
|
|
1563
|
+
return null;
|
|
1564
|
+
};
|
|
1299
1565
|
const learnable = () => trainer.spells
|
|
1300
|
-
.filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts))
|
|
1566
|
+
.filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts) && !needsTalent(ts))
|
|
1301
1567
|
.map((ts) => ({ ts, sp: app.ds.spell(ts.spell) })).filter((x) => x.sp)
|
|
1302
1568
|
.sort((x, y) => x.ts.reqLevel - y.ts.reqLevel || x.sp.name.localeCompare(y.sp.name));
|
|
1303
1569
|
return {
|
|
1304
1570
|
id: `trainer-${trainer.entry}`, title: trainer.name, hint: 'Enter: learn',
|
|
1305
1571
|
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
1572
|
rows(a) {
|
|
1307
|
-
const rows = [{ text: `${bold(trainer.name)} ${dim(`<${trainer.subname ?? `${className(a.ch.cls)} Trainer`}>`)} ${
|
|
1573
|
+
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
1574
|
const now = learnable();
|
|
1309
1575
|
if (!now.length) rows.push({ text: dim(' Nothing new at your level.'), header: true });
|
|
1310
1576
|
for (const { sp, ts } of now) {
|
|
1311
1577
|
const can = ts.cost <= a.ch.gold;
|
|
1312
1578
|
rows.push({
|
|
1313
|
-
text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${
|
|
1579
|
+
text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${coinText(ts.cost, 'free')}`, data: sp,
|
|
1314
1580
|
icon: sp.icon, sub: dim(`level ${ts.reqLevel}${sp.cost ? ` ${sp.cost} ${sp.powerType}` : ''}`),
|
|
1315
|
-
detail: () => spellLines(a, sp, ['', `Cost: ${
|
|
1581
|
+
detail: () => spellLines(a, sp, ['', `Cost: ${coinText(ts.cost, 'free')}`]),
|
|
1316
1582
|
act: () => a.result(a.g.learnFromTrainer(trainer.entry, sp.id), `You have learned ${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''}.`),
|
|
1317
1583
|
});
|
|
1318
1584
|
}
|
|
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);
|
|
1585
|
+
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
1586
|
if (later.length) {
|
|
1321
1587
|
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} ${
|
|
1588
|
+
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 });
|
|
1589
|
+
}
|
|
1590
|
+
// One row per talent, not per rank: the ranks are all locked behind the same point.
|
|
1591
|
+
const gated = new Map();
|
|
1592
|
+
for (const ts of trainer.spells) {
|
|
1593
|
+
const hit = !known().has(ts.spell) && fits(ts) && needsTalent(ts), sp = hit && a.ds.spell(ts.spell);
|
|
1594
|
+
if (!sp) continue;
|
|
1595
|
+
if (!gated.has(hit.t.id)) gated.set(hit.t.id, { ...hit, ranks: [] });
|
|
1596
|
+
gated.get(hit.t.id).ranks.push({ sp, ts });
|
|
1597
|
+
}
|
|
1598
|
+
if (gated.size) {
|
|
1599
|
+
rows.push({ text: '', header: true }, { text: 'Needs a talent', header: true });
|
|
1600
|
+
for (const { t, tree, ranks } of gated.values()) {
|
|
1601
|
+
ranks.sort((x, y) => x.ts.reqLevel - y.ts.reqLevel);
|
|
1602
|
+
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.`;
|
|
1603
|
+
rows.push({
|
|
1604
|
+
text: dim(`${sp.name} ${tree.name} talent, not taken`), data: sp, icon: sp.icon,
|
|
1605
|
+
sub: dim(ranks.map(({ sp: r, ts }) => `R${r.rank ?? '?'} lv${ts.reqLevel}`).join(' ')),
|
|
1606
|
+
detail: () => spellLines(a, sp, ['', yellow(why)]),
|
|
1607
|
+
act: () => a.say(why),
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1323
1610
|
}
|
|
1324
1611
|
return rows;
|
|
1325
1612
|
},
|