azeroth-shell 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +362 -85
- package/bin/azsh.mjs +35 -19
- package/package.json +12 -1
- package/src/autopilot/plan.ts +8 -11
- package/src/autopilot/view.ts +12 -11
- package/src/cli/combatLogFormat.ts +22 -0
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +145 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +298 -0
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +48 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +319 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +21 -3
- package/src/cli/tui.mjs +262 -102
- package/src/cli/tuiAutopilot.mjs +356 -0
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +633 -37
- package/src/cli/tuiCity.mjs +146 -41
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +211 -41
- package/src/cli/tuiCreate.mjs +230 -0
- package/src/cli/tuiDungeon.mjs +57 -21
- package/src/cli/tuiFight.mjs +467 -88
- package/src/cli/tuiFightPanes.mjs +42 -14
- package/src/cli/tuiFloor.mjs +21 -9
- package/src/cli/tuiGear.mjs +17 -14
- package/src/cli/tuiLoot.mjs +6 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +108 -0
- package/src/cli/tuiPartyStage.mjs +206 -0
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +27 -25
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +24 -1
- package/src/cli/tuiSearch.mjs +79 -27
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +360 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +633 -172
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +90 -7
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +114 -0
- package/src/data/icon-art.bin.gz +0 -0
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- package/src/domain/bg/match.ts +85 -24
- package/src/domain/bg/strategies.ts +144 -29
- package/src/domain/bg/types.ts +2 -0
- package/src/domain/bg/wsg.ts +35 -20
- package/src/domain/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/domain/quests/doQuest.ts +24 -6
- package/src/domain/rotation/build.ts +3031 -3031
- package/src/domain/rotation/classes/paladin.ts +16 -11
- package/src/domain/rotation/evaluate.ts +3 -0
- package/src/engine/combat/applySpell.ts +20 -9
- package/src/engine/combat/applySpellLate.ts +16 -12
- package/src/engine/combat/areaAura.ts +284 -283
- package/src/engine/combat/fighterFactory.ts +1 -1
- package/src/engine/combat/pickAction.ts +3494 -3449
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +19 -2
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/types.ts +4 -0
- package/src/quests/quests.ts +60 -0
package/src/cli/tuiViews.mjs
CHANGED
|
@@ -1,42 +1,58 @@
|
|
|
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 } 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
|
-
import { className } from '../domain/core/ids.ts';
|
|
7
|
-
import {
|
|
6
|
+
import { className, raceName } from '../domain/core/ids.ts';
|
|
7
|
+
import { nativeDisplayId, wornDisplay } from '../domain/character/displayForm.ts';
|
|
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';
|
|
8
11
|
import { maxHealth, maxPower } from '../domain/character/character.ts';
|
|
9
12
|
import { effectiveStats, effectiveArmor, attackPower, powerType } from '../domain/character/characterStats.ts';
|
|
10
13
|
import { critChanceDisplay, dodgeChanceDisplay, hitChance, weaponSkillIdForHand, skillBonusFor } from '../domain/character/characterRatings.ts';
|
|
11
|
-
import { COMBAT_SKILL, isLevelRangeCombatSkill, combatSkillDisplayName } from '../domain/character/combatSkills.ts';
|
|
14
|
+
import { COMBAT_SKILL, isLevelRangeCombatSkill, combatSkillDisplayName, isSpellFitByClassAndRace } from '../domain/character/combatSkills.ts';
|
|
12
15
|
import { skillMaxForLevel } from '../engine/combat/mitigation.ts';
|
|
13
16
|
import { knownUsableSpells } from '../domain/character/characterSpells.ts';
|
|
14
17
|
import { bagCapacity, EQUIP_SLOTS, BAG_SLOTS, SLOT_LABEL, itemSlotTargets, DUAL_WIELD_SPELL_ID } from '../domain/items/equipment.ts';
|
|
15
18
|
import { equippedEntry } from '../domain/items/itemInstance.ts';
|
|
16
|
-
import {
|
|
19
|
+
import { stackBoundToHolder } from '../domain/raid/partyVault.ts';
|
|
20
|
+
import { knows, learnGrantIds } from '../domain/character/spellbook.ts';
|
|
17
21
|
import { availablePoints, canSpend, canRefund, spentInTree } from '../domain/character/talents.ts';
|
|
18
22
|
import { offeredQuests, chainFollowUpOffer, grayLogQuests } from '../quests/quests.ts';
|
|
19
|
-
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';
|
|
20
25
|
import { questRewardChoices } from '../domain/quests/questRewardChoice.ts';
|
|
21
26
|
import { pushBuyback, buyBack as buyBackDomain } from '../domain/economy/trading.ts';
|
|
22
|
-
import { cityAccess, bankDialog, auctionDialog, mailDialog } from './tuiCity.mjs';
|
|
27
|
+
import { cityAccess, bankDialog, auctionDialog, mailDialog, tabStrip } from './tuiCity.mjs';
|
|
23
28
|
import { professionsView, profTrainerDialog, worldGatherRows, openSendGoods, reagentUses } from './tuiProfessions.mjs';
|
|
24
29
|
import { companionsView } from './tuiCompanions.mjs';
|
|
25
30
|
import { companionLevel, companionCharacter, companionSpellbook } from '../domain/bots/squad.ts';
|
|
26
31
|
import { squadJoinsPull } from './squad.mjs';
|
|
32
|
+
import { huntableCreatures, questTargets, isQuestMob, zoneWeaponTrainers } from '../world/world.ts';
|
|
33
|
+
import { canInteractWithCreature } from '../domain/creature/faction.ts';
|
|
27
34
|
import { barsDialog, playerBarsOwner } from './tuiBars.mjs';
|
|
28
35
|
import { petWorldRows, petDialog, hasPetClass } from './tuiPet.mjs';
|
|
29
36
|
import { portalRows } from './tuiDungeon.mjs';
|
|
37
|
+
import { conditionEditor, defaultConditionsForSpell, defaultItemRule } from './tuiRotationCond.mjs';
|
|
38
|
+
import { allyIndexOfSide } from '../engine/core/events.ts';
|
|
30
39
|
import { bulkDisenchantDialog, learnAllDialog } from './tuiBulk.mjs';
|
|
31
|
-
import { gearExtraRows, durabilityText, repairOne, slotRepairLines, enchantLines } from './tuiGear.mjs';
|
|
40
|
+
import { gearExtraRows, durabilityText, repairOne, repairAll, slotRepairLines, enchantLines } from './tuiGear.mjs';
|
|
32
41
|
import { outfitterDialog } from './tuiOutfitter.mjs';
|
|
33
42
|
import { castWithPicker } from './tuiEnchantTarget.mjs';
|
|
34
43
|
import { settingsDialog } from './tuiSettings.mjs';
|
|
35
44
|
import { macrosDialog } from './tuiMacros.mjs';
|
|
36
|
-
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';
|
|
37
46
|
import { talentBuildsMenu, myTalentLink } from './tuiTalents.mjs';
|
|
38
47
|
import { simLabDialog } from './tuiSimLab.mjs';
|
|
48
|
+
import { withWorldMap } from './tuiMap.mjs';
|
|
39
49
|
import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
|
|
50
|
+
import { preflightCard } from './tuiAutopilot.mjs';
|
|
51
|
+
import { creatureArt } from './modelArt.mjs';
|
|
52
|
+
import { withIcon, iconsOn, miniIcon } from './iconArt.mjs';
|
|
53
|
+
import { paperdollGrid, paperdollPlan } from './tuiPaperdoll.mjs';
|
|
54
|
+
import { talentGrid, talentPlan, talentTone, talentIcon } from './tuiTalentTree.mjs';
|
|
55
|
+
import { paperdollArt } from './paperdoll.mjs';
|
|
40
56
|
import { reputationDialog } from './tuiRep.mjs';
|
|
41
57
|
import { honorDialog, honorRankLine } from './tuiHonor.mjs';
|
|
42
58
|
import { openWorldSearch } from './tuiSearch.mjs';
|
|
@@ -56,7 +72,24 @@ export function conPaint(playerLevel, level) {
|
|
|
56
72
|
|
|
57
73
|
// ---- detail panes --------------------------------------------------------------------------------
|
|
58
74
|
|
|
75
|
+
// A spell's tooltip text with the tokens the bake left in filled from its own effects: $v max target
|
|
76
|
+
// level, $q misc value (Resurrection's mana), $f damage multiplier (Ferocious Bite). A leading id reads
|
|
77
|
+
// another spell's effect ($3026q1). Anything else stays as written.
|
|
78
|
+
export function spellText(ds, sp) {
|
|
79
|
+
const text = sp?.description?.trim();
|
|
80
|
+
if (!text?.includes('$')) return text;
|
|
81
|
+
const fx = (id, i) => (id ? ds.spell(Number(id)) : sp)?.rawEffects?.[(Number(i) || 1) - 1];
|
|
82
|
+
return text
|
|
83
|
+
.replace(/\$v\b/g, (m) => sp.maxTargetLevel != null ? String(sp.maxTargetLevel) : m)
|
|
84
|
+
.replace(/\$(\d*)q(\d?)/g, (m, id, i) => fx(id, i)?.miscValue != null ? String(fx(id, i).miscValue) : m)
|
|
85
|
+
.replace(/\$(\d*)f(\d)/g, (m, id, i) => fx(id, i)?.dmgMultiplier != null ? String(fx(id, i).dmgMultiplier) : m);
|
|
86
|
+
}
|
|
87
|
+
|
|
59
88
|
export function itemLines(app, item, extra = []) {
|
|
89
|
+
return withIcon(item?.icon, itemLinesText(app, item, extra));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function itemLinesText(app, item, extra = []) {
|
|
60
93
|
if (!item) return ['Unknown item'];
|
|
61
94
|
const out = [qualityPaint(item.quality)(bold(item.name))];
|
|
62
95
|
const kind = [item.slot, item.typeName].filter(Boolean).join(' · ');
|
|
@@ -68,19 +101,48 @@ export function itemLines(app, item, extra = []) {
|
|
|
68
101
|
if (item.armor) out.push(`${item.armor} Armor`);
|
|
69
102
|
if (item.block) out.push(`${item.block} Block`);
|
|
70
103
|
for (const s of item.stats ?? []) if (s.value) out.push(`${s.value > 0 ? '+' : ''}${s.value} ${STAT_LABEL[s.stat] ?? s.stat}`);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
104
|
+
for (const id of new Set(item.equipSpells ?? [])) {
|
|
105
|
+
const text = spellText(app.ds, app.ds.spell(id));
|
|
106
|
+
if (text) out.push(green(`Equip: ${text}`));
|
|
107
|
+
}
|
|
108
|
+
// ITEM_SPELLTRIGGER_CHANCE_ON_HIT 2.
|
|
109
|
+
for (const p of item.procs ?? []) {
|
|
110
|
+
const text = p.trigger === 2 && spellText(app.ds, app.ds.spell(p.spell));
|
|
111
|
+
if (text) out.push(green(`Chance on hit: ${text}`));
|
|
112
|
+
}
|
|
113
|
+
// The use spell's own tooltip text carries the Well Fed rider; a few still hold unresolved $ tokens.
|
|
114
|
+
const useText = spellText(app.ds, app.ds.spell(item.useSpell ?? item.useInCombat?.spellId));
|
|
115
|
+
if (useText && !useText.includes('$')) out.push(green(`Use: ${useText}`));
|
|
116
|
+
else {
|
|
117
|
+
// Same tick count rest.ts heals for: floor(duration / tick).
|
|
118
|
+
if (item.consumable) out.push(green(`Use: Restores ${item.consumable.amountPerTick * Math.max(1, Math.floor(item.consumable.durationMs / item.consumable.tickMs))} ${item.consumable.effect === 'drink' ? 'mana' : 'health'} over ${Math.round(item.consumable.durationMs / 1000)} sec. Must remain seated.`));
|
|
119
|
+
if (item.useInCombat?.effect === 'restore_health') out.push(green(`Use: Restores ${item.useInCombat.min} to ${item.useInCombat.max} health.`));
|
|
120
|
+
if (item.useInCombat?.effect === 'restore_power') out.push(green(`Use: Restores ${item.useInCombat.min} to ${item.useInCombat.max} mana.`));
|
|
121
|
+
}
|
|
74
122
|
if (item.requiredLevel > 1) out.push(`Requires Level ${item.requiredLevel}`);
|
|
75
123
|
if (item.allowClass?.length) out.push(`Classes: ${item.allowClass.map(className).join(', ')}`);
|
|
76
124
|
if (item.bagSlots) out.push(`${item.bagSlots} Slot Bag`);
|
|
125
|
+
const set = item.setId && app.ds.itemSet(item.setId);
|
|
126
|
+
if (set) {
|
|
127
|
+
const worn = new Set(Object.values(app.ch?.equipped ?? {}).map(equippedEntry));
|
|
128
|
+
const have = set.items.filter((e) => worn.has(e)).length;
|
|
129
|
+
out.push('', yellow(`${set.name ?? 'Item set'} (${have}/${set.items.length})`));
|
|
130
|
+
for (const b of set.bonuses) {
|
|
131
|
+
const text = spellText(app.ds, app.ds.spell(b.spell));
|
|
132
|
+
if (text) out.push((have >= b.pieces ? green : gray)(`(${b.pieces}) Set: ${text}`));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
77
135
|
if (item.description) out.push(yellow(`"${item.description}"`));
|
|
78
|
-
if (item.sellPrice) out.push(dim(`Sells for ${
|
|
136
|
+
if (item.sellPrice) out.push(dim(`Sells for ${coinText(item.sellPrice)}`));
|
|
79
137
|
out.push(...extra);
|
|
80
138
|
return out;
|
|
81
139
|
}
|
|
82
140
|
|
|
83
141
|
export function spellLines(app, sp, extra = []) {
|
|
142
|
+
return withIcon(sp?.icon, spellLinesText(app, sp, extra));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function spellLinesText(app, sp, extra = []) {
|
|
84
146
|
if (!sp) return ['Unknown spell'];
|
|
85
147
|
const out = [bold(sp.name) + (sp.rank ? dim(` Rank ${sp.rank}`) : '')];
|
|
86
148
|
const bits = [];
|
|
@@ -90,7 +152,8 @@ export function spellLines(app, sp, extra = []) {
|
|
|
90
152
|
if (sp.rangeMax) bits.push(`${sp.rangeMax} yd range`);
|
|
91
153
|
out.push(dim(bits.join(' ')));
|
|
92
154
|
if (sp.reagents?.length) out.push(dim('Reagents: ' + sp.reagents.map((r) => `${app.itemLabel(r.item)}${r.count > 1 ? ` x${r.count}` : ''}`).join(', ')));
|
|
93
|
-
|
|
155
|
+
const text = spellText(app.ds, sp);
|
|
156
|
+
if (text) out.push(yellow(text)); // the pane wraps it, beside the icon while they share its rows
|
|
94
157
|
const cd = app.g.spellCooldownRemaining(sp.id);
|
|
95
158
|
if (cd > 0) out.push(red(`Cooldown: ${secondsText(cd)} left`));
|
|
96
159
|
out.push(...extra);
|
|
@@ -104,7 +167,9 @@ export function creatureLines(app, c) {
|
|
|
104
167
|
out.push(`${hp} health hits ${c.dmgMin}-${c.dmgMax} every ${(c.attackTimeMs / 1000).toFixed(1)} s`);
|
|
105
168
|
if (c.armor) out.push(`${c.armor} armor`);
|
|
106
169
|
if (c.xp) out.push(dim(`about ${c.xp} xp at your level`));
|
|
107
|
-
|
|
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'));
|
|
108
173
|
const wanted = app.g.state.questlog.quests.flatMap((qs) => {
|
|
109
174
|
const q = app.ds.quest(qs.entry);
|
|
110
175
|
const k = q?.killTargets.find((t) => t.creatureEntry === c.entry);
|
|
@@ -113,12 +178,25 @@ export function creatureLines(app, c) {
|
|
|
113
178
|
out.push(...wanted);
|
|
114
179
|
if ((app.g.state.squad ?? []).length) out.push(squadJoinsPull(app.g, app.ds, c.entry) ? cyan('Your companions join this fight.') : dim('You take this one alone (squad mode, m on Companions).'));
|
|
115
180
|
out.push('', dim('Enter: fight live f: auto-resolve'));
|
|
181
|
+
// The creature itself under the text, as large as the pane has room for (modelArt.mjs).
|
|
182
|
+
const L = app.layout?.();
|
|
183
|
+
const art = L && c.displayId ? creatureArt(c.displayId, L.detailW - 2, L.mainH - out.length - 1) : null;
|
|
184
|
+
if (art) out.push('', ...art);
|
|
116
185
|
return out;
|
|
117
186
|
}
|
|
118
187
|
|
|
188
|
+
/** A person in the world: the text, then the NPC's creature drawn under it as large as the pane has room for. */
|
|
189
|
+
export function npcLines(app, entry, text) {
|
|
190
|
+
const L = app.layout?.(), id = app.ds.creature(entry)?.displayId;
|
|
191
|
+
if (!L || !id) return text;
|
|
192
|
+
const w = L.detailW - 2, used = text.reduce((n, line) => n + (vis(line) > w ? wrapLines(line, w).length : 1), 0);
|
|
193
|
+
const art = creatureArt(id, w, L.mainH - used - 1);
|
|
194
|
+
return art ? [...text, '', ...art] : text;
|
|
195
|
+
}
|
|
196
|
+
|
|
119
197
|
export function questLines(app, q, qs) {
|
|
120
198
|
const ch = app.ch;
|
|
121
|
-
const out = [bold(q.title) + dim(` level ${q.questLevel}${q.minLevel > ch.level ? red(` requires ${q.minLevel}`) : ''}`)];
|
|
199
|
+
const out = [conPaint(ch.level, q.questLevel)(bold(q.title)) + dim(` level ${q.questLevel}${q.minLevel > ch.level ? red(` requires ${q.minLevel}`) : ''}`)];
|
|
122
200
|
if (qs) out.push(qs.status === 'complete' ? green('Ready to turn in') : qs.status === 'failed' ? red('Failed') : yellow('In progress'));
|
|
123
201
|
out.push(...objectiveLines(app, q, qs).map((o) => ` ${o}`));
|
|
124
202
|
const rew = rewardLines(app, q);
|
|
@@ -142,29 +220,49 @@ export function objectiveLines(app, q, qs) {
|
|
|
142
220
|
|
|
143
221
|
export function rewardLines(app, q) {
|
|
144
222
|
const out = [];
|
|
145
|
-
if (q.rewXP) out.push(`${q.rewXP} experience`);
|
|
146
|
-
if (q.rewMoney) out.push(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
223
|
+
if (q.rewXP) out.push(magenta(`${q.rewXP} experience`));
|
|
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)}`);
|
|
150
229
|
return out;
|
|
151
230
|
}
|
|
152
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
|
+
|
|
153
251
|
// ---- tabs ------------------------------------------------------------------------------------------
|
|
154
252
|
|
|
155
253
|
export function makeTabs(app) {
|
|
156
254
|
return [
|
|
157
|
-
{ label: 'World', view: worldView(app) },
|
|
158
|
-
{ label: 'Quests', view: questsView(app) },
|
|
159
|
-
{ label: 'Bags', view: bagsView(app) },
|
|
160
|
-
{ label: '
|
|
161
|
-
{ label: 'Spells', view: spellsView(app) },
|
|
162
|
-
{ label: 'Rotation', view: rotationView(app) },
|
|
163
|
-
{ label: 'Talents', view: talentsView(app) },
|
|
164
|
-
{ label: 'Travel', view: travelView(app) },
|
|
165
|
-
{ label: 'Professions', short: 'Profs', view: professionsView(app) },
|
|
166
|
-
{ label: 'Companions', short: 'Party', view: companionsView(app) },
|
|
167
|
-
{ label: '
|
|
255
|
+
{ label: 'World', icon: 'inv_misc_map_01', view: worldView(app) },
|
|
256
|
+
{ label: 'Quests', icon: 'inv_misc_note_01', view: questsView(app) },
|
|
257
|
+
{ label: 'Bags', icon: 'inv_misc_bag_08', view: bagsView(app) },
|
|
258
|
+
{ label: 'Character', short: 'Char', icon: 'inv_misc_head_human_01', view: characterView(app) },
|
|
259
|
+
{ label: 'Spells', icon: 'inv_misc_book_09', view: spellsView(app) },
|
|
260
|
+
{ label: 'Rotation', icon: 'ability_dualwield', view: rotationView(app) },
|
|
261
|
+
{ label: 'Talents', icon: 'spell_nature_naturetouchgrow', view: talentsView(app) },
|
|
262
|
+
{ label: 'Travel', icon: 'spell_nature_swiftness', view: travelView(app) },
|
|
263
|
+
{ label: 'Professions', short: 'Profs', icon: 'trade_blacksmithing', view: professionsView(app) },
|
|
264
|
+
{ label: 'Companions', short: 'Party', icon: 'spell_holy_prayerofhealing', view: companionsView(app) },
|
|
265
|
+
{ label: 'Settings', icon: 'inv_misc_gear_01', view: settingsDialog(app) },
|
|
168
266
|
];
|
|
169
267
|
}
|
|
170
268
|
|
|
@@ -173,34 +271,44 @@ function worldView(app) {
|
|
|
173
271
|
id: 'world', title: 'World', hint: 'Enter: open / fight',
|
|
174
272
|
keys: {
|
|
175
273
|
s: { hint: 'search', fn: (a) => openWorldSearch(a) },
|
|
274
|
+
S: { fn: (a) => openWorldSearch(a, '', true) },
|
|
176
275
|
f: { hint: 'quick fight', fn: (a, row) => { if (row?.data?.creature) a.cmds.commands.fight(String(row.data.creature.entry)); } },
|
|
177
|
-
t: { fn: (a, row) => {
|
|
276
|
+
t: { fn: (a, row) => { if (row?.data?.creature) tame(a, row.data.creature); } },
|
|
178
277
|
P: { fn: (a, row) => { if (row?.data?.creature) pickPocket(a, row.data.creature); } },
|
|
179
|
-
a: { hint: 'autopilot', fn: (a) =>
|
|
278
|
+
a: { hint: 'autopilot', fn: (a) => a.push(preflightCard(a)) },
|
|
279
|
+
c: { hint: 'characters', fn: (a) => { void a.logout(); } },
|
|
180
280
|
},
|
|
181
281
|
rows(a) {
|
|
182
282
|
const ds = a.ds, g = a.g, z = a.zone(), ch = a.ch;
|
|
183
283
|
if (!z) return [{ text: 'You are nowhere.', header: true }];
|
|
184
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);
|
|
185
287
|
const people = [];
|
|
186
|
-
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: () => [bold(d.name), `${className(t.cls)} trainer`, '', dim('Enter: see what you can learn')], act: () => a.push(trainerDialog(a, d)) }); }
|
|
187
|
-
for (const
|
|
188
|
-
|
|
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)) });
|
|
290
|
+
const innEntries = new Set((z.innkeepers ?? []).map((i) => i.entry)); // innkeepers sell too, but list once, as the inn
|
|
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)) });
|
|
189
293
|
for (const p of z.profTrainers ?? []) {
|
|
190
|
-
const d = ds.profTrainer(p);
|
|
294
|
+
const d = canTalk(p) && ds.profTrainer(p);
|
|
191
295
|
if (!d) continue;
|
|
192
296
|
const prof = ds.profession(d.skill)?.name ?? d.skill;
|
|
193
297
|
const sk = ch.skills[d.skill];
|
|
194
|
-
people.push({ text: `${d.name} ${dim(`<${prof} Trainer>`)}${sk ? dim(` ${sk.current}/${sk.max}`) : ''}`, detail: () => [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)) });
|
|
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)) });
|
|
195
299
|
}
|
|
196
300
|
people.push(...ridingRows(a));
|
|
197
|
-
|
|
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) });
|
|
198
305
|
// City services: the bank in capitals and banker towns, the auction house in capitals, the mailbox by every inn.
|
|
199
306
|
const city = cityAccess(a);
|
|
200
307
|
const services = [];
|
|
201
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)) });
|
|
202
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)) }); }
|
|
203
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));
|
|
204
312
|
if (services.length) rows.push({ text: '', header: true }, { text: 'Services', header: true }, ...services);
|
|
205
313
|
rows.push(...portalRows(a));
|
|
206
314
|
const petRows = petWorldRows(a);
|
|
@@ -215,27 +323,23 @@ function worldView(app) {
|
|
|
215
323
|
if (!dest) continue;
|
|
216
324
|
rows.push({ text: `${cyan(dest.name)} ${dim(`levels ${dest.levelRange[0]}-${dest.levelRange[1]}`)}`, detail: () => travelPreviewLines(a, dest), act: () => travelTo(a, dest) });
|
|
217
325
|
}
|
|
218
|
-
|
|
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));
|
|
219
328
|
if (mobs.length) {
|
|
220
329
|
rows.push({ text: '', header: true }, { text: `Creatures (${mobs.length})`, header: true });
|
|
330
|
+
const targets = questTargets(ds, g.state.questlog);
|
|
221
331
|
for (const m of mobs) {
|
|
222
|
-
const wanted =
|
|
223
|
-
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) });
|
|
224
334
|
}
|
|
225
335
|
}
|
|
226
|
-
// Last
|
|
227
|
-
// class, race and exclusive-group gates applied); `canAccept` false means visible but level- or
|
|
228
|
-
// reputation-locked (shown grey, like a silver !).
|
|
229
|
-
const have = new Map(g.state.questlog.quests.map((qs) => [qs.entry, qs]));
|
|
230
|
-
const offered = offeredQuests(ds, z.id, ch, g.state.questlog).filter((o) => !have.has(o.quest.entry) && (o.canAccept || o.quest.minLevel <= ch.level + 5));
|
|
336
|
+
// Last: hand-ins and the zone's offers (the Quests tab shows the same list under its log).
|
|
231
337
|
const ready = g.state.questlog.quests.filter((qs) => qs.status === 'complete').map((qs) => ({ qs, q: ds.quest(qs.entry) })).filter((x) => x.q);
|
|
338
|
+
const offered = zoneOfferRows(a);
|
|
232
339
|
if (offered.length || ready.length) {
|
|
233
340
|
rows.push({ text: '', header: true }, { text: 'Quests', header: true });
|
|
234
|
-
for (const { q, qs } of ready) rows.push({ text: `${green('?')} ${q.title}
|
|
235
|
-
|
|
236
|
-
const q = o.quest;
|
|
237
|
-
rows.push({ text: `${o.canAccept ? yellow('!') : dim('·')} ${o.canAccept ? q.title : dim(q.title)} ${dim(`L${q.questLevel}`)}${!o.canAccept && q.minLevel > ch.level ? dim(` at level ${q.minLevel}`) : ''}`, detail: () => questLines(a, q), act: () => a.push(questDialog(a, q, 'offer')) });
|
|
238
|
-
}
|
|
341
|
+
for (const { q, qs } of ready) rows.push({ text: `${green('?')} ${conPaint(ch.level, q.questLevel)(`${q.title} L${q.questLevel}`)} ${green('turn in')}`, detail: () => questLines(a, q, qs), act: () => a.push(questDialog(a, q, 'log')) });
|
|
342
|
+
rows.push(...offered);
|
|
239
343
|
}
|
|
240
344
|
return rows;
|
|
241
345
|
},
|
|
@@ -245,10 +349,11 @@ function worldView(app) {
|
|
|
245
349
|
export function travelPreviewLines(app, dest) {
|
|
246
350
|
const pv = app.g.previewTravel(dest.id);
|
|
247
351
|
const out = [bold(dest.name), dim(`levels ${dest.levelRange[0]}-${dest.levelRange[1]}${dest.faction ? `, ${dest.faction}` : ''}`), ''];
|
|
248
|
-
if (!pv.ok) { out.push(red(pv.reason)); return out; }
|
|
249
|
-
out.push(`${secondsText(pv.totalSeconds * 1000)} of travel${pv.totalCopper ? `, ${
|
|
250
|
-
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)})`));
|
|
251
|
-
|
|
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 });
|
|
252
357
|
}
|
|
253
358
|
|
|
254
359
|
export function travelTo(app, dest) {
|
|
@@ -257,7 +362,7 @@ export function travelTo(app, dest) {
|
|
|
257
362
|
const res = app.g.travelTo(dest.id);
|
|
258
363
|
if (!app.result(res)) return;
|
|
259
364
|
app.vendor = null; app.cmds.setVendor(null);
|
|
260
|
-
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)}` : ''}`)}`);
|
|
261
366
|
landOnTab(app);
|
|
262
367
|
}
|
|
263
368
|
|
|
@@ -270,9 +375,23 @@ export function landOnTab(app) {
|
|
|
270
375
|
const v = app.tabs[i]?.view; if (v) { v.cursor = 0; v.scroll = 0; }
|
|
271
376
|
}
|
|
272
377
|
|
|
378
|
+
/** The quests this zone offers that are not in the log: the same offer list the autopilot reads (chain, class,
|
|
379
|
+
* race and exclusive-group gates applied). `canAccept` false means visible but level- or reputation-locked
|
|
380
|
+
* (shown grey, like a silver !); quests more than five levels above you are left out. */
|
|
381
|
+
export function zoneOfferRows(a) {
|
|
382
|
+
const z = a.zone(), ch = a.ch;
|
|
383
|
+
if (!z) return [];
|
|
384
|
+
const have = new Set(a.g.state.questlog.quests.map((qs) => qs.entry));
|
|
385
|
+
const offered = offeredQuests(a.ds, z.id, ch, a.g.state.questlog).filter((o) => !have.has(o.quest.entry) && (o.canAccept || o.quest.minLevel <= ch.level + 5));
|
|
386
|
+
return offered.map((o) => {
|
|
387
|
+
const q = o.quest;
|
|
388
|
+
return { text: `${o.canAccept ? yellow('!') : dim('·')} ${o.canAccept ? conPaint(ch.level, q.questLevel)(`${q.title} L${q.questLevel}`) : dim(`${q.title} L${q.questLevel}`)}${!o.canAccept && q.minLevel > ch.level ? dim(` at level ${q.minLevel}`) : ''}`, detail: () => questLines(a, q), act: () => a.push(questDialog(a, q, 'offer')) };
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
273
392
|
function questsView(app) {
|
|
274
393
|
return {
|
|
275
|
-
id: 'quests', title: 'Quest log', hint: 'Enter: open', empty: 'Your quest log is empty. Look for ! in the world.',
|
|
394
|
+
id: 'quests', title: 'Quest log', hint: 'Enter: open', empty: 'Your quest log is empty and this zone offers nothing. Look for ! in the world.',
|
|
276
395
|
keys: {
|
|
277
396
|
d: { hint: 'do quest', fn: (a, row) => { if (row?.data) doQuest(a, row.data.q); } },
|
|
278
397
|
x: { hint: 'abandon', fn: (a, row) => { if (row?.data) a.confirm(`Abandon ${row.data.q.title}?`, () => a.result(a.g.abandonQuest(row.data.q.entry), `Abandoned ${row.data.q.title}.`)); } },
|
|
@@ -285,8 +404,14 @@ function questsView(app) {
|
|
|
285
404
|
rows.push({ text: `${log.length} of 20 quests`, header: true });
|
|
286
405
|
for (const x of log) {
|
|
287
406
|
const st = x.qs.status === 'complete' ? green('(Complete)') : x.qs.status === 'failed' ? red('(Failed)') : a.cmds.questBlacklist.has(x.q.entry) ? dim('(autopilot skips)') : '';
|
|
288
|
-
rows.push({ text: `${x.q.title}
|
|
407
|
+
rows.push({ text: `${conPaint(a.ch.level, x.q.questLevel)(`${x.q.title} L${x.q.questLevel}`)} ${st}`, data: x, detail: () => questLines(a, x.q, x.qs), act: () => a.push(questDialog(a, x.q, 'log')) });
|
|
289
408
|
}
|
|
409
|
+
// Below the log: what this zone offers that you have not taken, so you need not scroll the World tab for it.
|
|
410
|
+
const offered = zoneOfferRows(a);
|
|
411
|
+
if (offered.length) {
|
|
412
|
+
if (log.length) rows.push({ text: '', header: true });
|
|
413
|
+
rows.push({ text: `Available in ${a.zone()?.name ?? 'this zone'} (${offered.length})`, header: true }, ...offered);
|
|
414
|
+
} else if (!log.length) return [];
|
|
290
415
|
return rows;
|
|
291
416
|
},
|
|
292
417
|
};
|
|
@@ -299,23 +424,33 @@ export function questDialog(app, q, mode) {
|
|
|
299
424
|
rows(a) {
|
|
300
425
|
const W = a.layout().listW - 4;
|
|
301
426
|
const ch = a.ch, s = qs();
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
rows.push({ text:
|
|
308
|
-
|
|
309
|
-
|
|
427
|
+
// Each part reads apart: the title in its level colour, cyan section headings, the story in plain
|
|
428
|
+
// text, the objective in bold with its counts, rewards in their own paint.
|
|
429
|
+
const P = (t) => ({ text: t, header: true, plain: true });
|
|
430
|
+
const rows = [P(`${conPaint(ch.level, q.questLevel)(bold(q.title))} ${dim(`L${q.questLevel}`)}`)];
|
|
431
|
+
const text = (t, paint = (l) => l) => wrapLines(questText(t, ch), W).map((l) => P(paint(l)));
|
|
432
|
+
const section = (title, lines) => { if (lines.length) rows.push(P(''), { text: title, header: true }, ...lines); };
|
|
433
|
+
const story = () => section(s?.status === 'complete' ? 'Completion' : 'Description',
|
|
434
|
+
text(s?.status === 'complete' ? q.offerRewardText || q.endText || '' : q.detailsText || q.requestItemsText || ''));
|
|
435
|
+
const objectives = () => section('Objectives', [...text(q.objectivesText ?? '', bold), ...objectiveLines(a, q, s).map((o) => P(` ${o}`))]);
|
|
436
|
+
// Offered, the story leads as the giver tells it; in the log, what is left to do comes first.
|
|
437
|
+
if (mode === 'offer' || s?.status === 'complete') { story(); objectives(); } else { objectives(); story(); }
|
|
438
|
+
section('Rewards', rewardRows(a, q));
|
|
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; }
|
|
310
443
|
if (mode === 'offer') {
|
|
311
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); } });
|
|
312
446
|
rows.push({ text: dim('Decline'), act: () => a.pop() });
|
|
313
447
|
} else {
|
|
314
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));
|
|
315
450
|
// A repeatable with a fixed reward hands in every round the bags pay for in one go.
|
|
316
451
|
const rounds = s?.status === 'complete' && isRepeatableQuest(q) && questRewardChoices(q).length <= 1 ? questRepeatsAvailable(q, a.g.state.inventory, a.ch.zoneId) : 0;
|
|
317
452
|
if (rounds >= 2) rows.push({ text: green(`Hand it in ${rounds} times`), act: () => repeatTurnIn(a, q) });
|
|
318
|
-
else rows.push({ text:
|
|
453
|
+
else rows.push({ text: 'Do the quest', act: () => doQuest(a, q) });
|
|
319
454
|
rows.push({ text: red('Abandon the quest'), act: () => a.confirm(`Abandon ${q.title}?`, () => { if (a.result(a.g.abandonQuest(q.entry), `Abandoned ${q.title}.`)) a.popTo('__none__'); }) });
|
|
320
455
|
rows.push({ text: dim('Close'), act: () => a.pop() });
|
|
321
456
|
}
|
|
@@ -326,12 +461,55 @@ export function questDialog(app, q, mode) {
|
|
|
326
461
|
return view;
|
|
327
462
|
}
|
|
328
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
|
+
|
|
329
507
|
/** Do Quest: the autopilot works this one quest (travel, fights, objects, hand-in) and stops when it is done. */
|
|
330
508
|
function doQuest(app, q) {
|
|
331
509
|
const s = app.g.state.questlog.quests.find((x) => x.entry === q.entry);
|
|
332
510
|
if (s?.status === 'complete' && q.rewChoiceItems?.length) { turnIn(app, q); return; }
|
|
333
511
|
app.stack = [];
|
|
334
|
-
startAutopilot(app,
|
|
512
|
+
startAutopilot(app, Infinity, undefined, q.entry);
|
|
335
513
|
}
|
|
336
514
|
|
|
337
515
|
function repeatTurnIn(app, q) {
|
|
@@ -339,7 +517,7 @@ function repeatTurnIn(app, q) {
|
|
|
339
517
|
if (!app.result(r)) return;
|
|
340
518
|
const bits = [`${r.turnedIn} times`];
|
|
341
519
|
if (r.xp) bits.push(`+${r.xp} xp`);
|
|
342
|
-
if (r.money) bits.push(`+${
|
|
520
|
+
if (r.money) bits.push(`+${coinText(r.money)}`);
|
|
343
521
|
app.say(green(`Quest complete: ${q.title}`) + dim(` ${bits.join(', ')}`));
|
|
344
522
|
for (const lv of r.leveledTo) { app.say(bold(green(`You have reached level ${lv}!`))); app.flashMsg(`Level ${lv}!`); }
|
|
345
523
|
app.stack = [];
|
|
@@ -362,7 +540,7 @@ function turnIn(app, q) {
|
|
|
362
540
|
if (!app.result(res, `Quest complete: ${q.title}`)) return;
|
|
363
541
|
const bits = [];
|
|
364
542
|
if (res.xp) bits.push(`+${res.xp} xp`);
|
|
365
|
-
if (res.money) bits.push(`+${
|
|
543
|
+
if (res.money) bits.push(`+${coinText(res.money)}`);
|
|
366
544
|
for (const it of res.items) bits.push(app.itemLabel(it.item) + (it.count > 1 ? ` x${it.count}` : ''));
|
|
367
545
|
for (const s of res.spells ?? []) bits.push(`learned ${s.name ?? ''}`.trim());
|
|
368
546
|
if (bits.length) app.say(' ' + bits.join(', '));
|
|
@@ -376,6 +554,7 @@ function turnIn(app, q) {
|
|
|
376
554
|
if (q.rewChoiceItems?.length) {
|
|
377
555
|
app.pick('Choose your reward', q.rewChoiceItems.map((it, i) => ({
|
|
378
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 ?? ''),
|
|
379
558
|
detail: () => itemLines(app, app.ds.item(it.item)),
|
|
380
559
|
value: i,
|
|
381
560
|
})), finish);
|
|
@@ -386,7 +565,7 @@ function bagsView(app) {
|
|
|
386
565
|
const slotOf = (a, row) => (row?.data && !('keyring' in row.data) ? a.g.state.inventory.stacks.indexOf(row.data) : -1);
|
|
387
566
|
const onStack = (fn) => (a, row) => { const i = slotOf(a, row); if (i >= 0) fn(a, i); };
|
|
388
567
|
return {
|
|
389
|
-
id: 'bags', title: 'Bags', hint: 'Enter: use / equip / open
|
|
568
|
+
id: 'bags', title: 'Bags', hint: 'Enter: use / equip / open', empty: 'Your bags are empty.',
|
|
390
569
|
keys: {
|
|
391
570
|
m: { hint: 'item menu', fn: onStack(itemMenu) },
|
|
392
571
|
s: { hint: 'sell', fn: (a, row) => { if (!a.vendor) { a.flashMsg('Open a vendor first (World tab).'); return; } const i = slotOf(a, row); if (i >= 0) sellStack(a, i); } },
|
|
@@ -406,7 +585,7 @@ function bagsView(app) {
|
|
|
406
585
|
rows(a) {
|
|
407
586
|
const st = a.g.state.inventory.stacks;
|
|
408
587
|
const ups = new Set(a.g.suggestedEquipmentUpgrades().filter((u) => !u.holderId).map((u) => u.entry));
|
|
409
|
-
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 }];
|
|
410
589
|
st.forEach((s, i) => {
|
|
411
590
|
const it = a.ds.item(s.item);
|
|
412
591
|
const name = qualityPaint(it?.quality)(it?.name ?? `item ${s.item}`);
|
|
@@ -414,6 +593,7 @@ function bagsView(app) {
|
|
|
414
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') : '';
|
|
415
594
|
rows.push({
|
|
416
595
|
text: `${name}${s.count > 1 ? ` x${s.count}` : ''}${tag}${ups.has(s.item) ? green(' ▲') : ''}`, data: s,
|
|
596
|
+
icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${coinText(it.sellPrice * s.count)}` : ''}`),
|
|
417
597
|
detail: () => itemLines(a, it, [
|
|
418
598
|
'', dim(`Enter: ${verb ? verb.toLowerCase() : 'more'} m: more${s.count > 1 ? ' /: split' : ''} [ ]: move d: destroy`),
|
|
419
599
|
...(a.vendor ? [dim(`s: sell to ${a.vendor.name}`)] : []),
|
|
@@ -433,9 +613,19 @@ function wornItem(app, slot) {
|
|
|
433
613
|
return entry == null ? null : app.ds.item(entry) ?? null;
|
|
434
614
|
}
|
|
435
615
|
|
|
436
|
-
/** The engine's equipment helper: what in your bags
|
|
616
|
+
/** The engine's equipment helper: what in your bags, or unbound in a companion's (a crafter's product), beats what you wear. */
|
|
437
617
|
function upgradeList(app) {
|
|
438
|
-
return app.g.suggestedEquipmentUpgrades()
|
|
618
|
+
return app.g.suggestedEquipmentUpgrades();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Companions you own, squad first, each once. */
|
|
622
|
+
const ownedCompanions = (g) => [...new Map([...(g.state.squad ?? []), ...Object.values(g.state.botRoster ?? {})].map((c) => [c.id, c])).values()];
|
|
623
|
+
|
|
624
|
+
/** Wear `entry` in `slot`, first taking one unbound copy from companion `holderId` when it is theirs. */
|
|
625
|
+
function equipFrom(a, entry, slot, holderId) {
|
|
626
|
+
if (holderId && !a.result(a.g.takeItemFromBot(holderId, entry, 1), '')) return false;
|
|
627
|
+
const r = a.g.equip(entry, slot);
|
|
628
|
+
return a.result(r, r.ok ? `You equip ${a.ds.item(entry)?.name ?? entry}.` : '');
|
|
439
629
|
}
|
|
440
630
|
|
|
441
631
|
/** Lines comparing a candidate with the item worn in `slot`: armor, damage and stat deltas. */
|
|
@@ -454,47 +644,72 @@ function compareLines(app, it, worn) {
|
|
|
454
644
|
return out;
|
|
455
645
|
}
|
|
456
646
|
|
|
457
|
-
|
|
647
|
+
/** The Character tab: the paperdoll (your gear around your body), your numbers under the tooltip, s for the rest. */
|
|
648
|
+
function characterView(app) {
|
|
458
649
|
return {
|
|
459
|
-
id: 'gear', title: '
|
|
650
|
+
id: 'gear', title: 'Character', hint: 'Enter: change what you wear',
|
|
651
|
+
detailFoot: (a, w) => statPanel(a, w),
|
|
460
652
|
keys: {
|
|
653
|
+
s: { hint: 'stats', fn: (a) => a.push(characterDetailsDialog(a)) },
|
|
461
654
|
u: { hint: 'equip all upgrades', fn: (a) => {
|
|
462
|
-
if (!upgradeList(a).length) { a.flashMsg(
|
|
463
|
-
|
|
655
|
+
if (!upgradeList(a).length) { a.flashMsg("Nothing in your bags or your companions' beats what you wear."); return; }
|
|
656
|
+
// Best first, re-scored after each piece (two rings, a two-hander). Works in suggest mode too,
|
|
657
|
+
// where autoEquipUpgrades stands down. Each entry is tried once, so displaced pieces cannot ping-pong.
|
|
658
|
+
let n = 0;
|
|
659
|
+
const tried = new Set();
|
|
660
|
+
for (let i = 0; i < 16; i++) {
|
|
661
|
+
const u = upgradeList(a).find((x) => !tried.has(x.entry));
|
|
662
|
+
if (!u) break;
|
|
663
|
+
tried.add(u.entry);
|
|
664
|
+
if (u.holderId && !a.g.takeItemFromBot(u.holderId, u.entry, 1).ok) continue;
|
|
665
|
+
if (a.g.equip(u.entry, u.slot).ok) n++;
|
|
666
|
+
}
|
|
464
667
|
a.say(n ? green(`You put on ${plural(n, 'upgrade')}.`) : red('The upgrades could not be equipped.'));
|
|
465
668
|
} },
|
|
466
669
|
x: { hint: 'take off', fn: (a, row) => { if (row?.data?.it) a.result(a.g.unequip(row.data.slot), `You take off ${row.data.it.name}.`); } },
|
|
467
670
|
r: { hint: 'repair', fn: (a, row) => { if (row?.data?.it) repairOne(a, row.data.slot); } },
|
|
671
|
+
R: { hint: 'repair all', fn: (a) => repairAll(a) },
|
|
468
672
|
o: { hint: 'outfits', fn: (a) => a.push(outfitterDialog(a)) },
|
|
469
673
|
},
|
|
674
|
+
// Drawn as the character sheet (tuiPaperdoll.mjs) when colour is on and the window has room.
|
|
675
|
+
grid: (a, rows, w, h) => paperdollGrid(a, rows, w, h, (cols, lines) => playerArt(a, cols, lines)?.lines ?? null, characterTitle(a)),
|
|
470
676
|
rows(a) {
|
|
471
677
|
const rows = [];
|
|
472
678
|
const ups = upgradeList(a);
|
|
473
|
-
|
|
474
|
-
|
|
679
|
+
const extras = gearExtraRows(a);
|
|
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 });
|
|
682
|
+
if (ups.length && !sheet) {
|
|
683
|
+
rows.push({ text: `${green('▲')} Better gear for you (${ups.length}) ${dim('Enter: put on u: put on all')}`, header: true });
|
|
475
684
|
for (const u of ups) {
|
|
476
685
|
const it = a.ds.item(u.entry), cur = u.currentEntry != null ? a.ds.item(u.currentEntry) : null;
|
|
477
686
|
rows.push({
|
|
478
|
-
text: ` ${fit(SLOT_LABEL[u.slot] ?? u.slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? u.entry)} ${green(`+${Math.round(u.delta)}`)}${cur ? dim(` replaces ${cur.name}`) : ''}${u.displaces?.length ? yellow(` frees ${u.displaces.map((s) => SLOT_LABEL[s] ?? s).join(', ')}`) : ''}`,
|
|
687
|
+
text: ` ${fit(SLOT_LABEL[u.slot] ?? u.slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? u.entry)} ${green(`+${Math.round(u.delta)}`)}${cur ? dim(` replaces ${cur.name}`) : ''}${u.displaces?.length ? yellow(` frees ${u.displaces.map((s) => SLOT_LABEL[s] ?? s).join(', ')}`) : ''}${u.holderId ? cyan(` ${u.holderName} has it`) : ''}`,
|
|
479
688
|
data: { upgrade: u },
|
|
480
|
-
detail: () => itemLines(a, it, compareLines(a, it, cur)),
|
|
481
|
-
act: () =>
|
|
689
|
+
detail: () => itemLines(a, it, [...compareLines(a, it, cur), ...(u.holderId ? ['', dim(`In ${u.holderName}'s bags. Enter takes it and puts it on.`)] : [])]),
|
|
690
|
+
act: () => equipFrom(a, u.entry, u.slot, u.holderId),
|
|
482
691
|
});
|
|
483
692
|
}
|
|
484
693
|
rows.push({ text: '', header: true });
|
|
485
694
|
}
|
|
486
|
-
rows.push({ text: `What you wear ${dim('Enter: pick from bags' + (cityAccess(a).bank ? '
|
|
695
|
+
if (!sheet) rows.push({ text: `What you wear ${dim('Enter: pick from bags' + (cityAccess(a).bank ? ', bank' : '') + ' and companions x: take off')}`, header: true });
|
|
487
696
|
for (const slot of [...EQUIP_SLOTS, ...BAG_SLOTS]) {
|
|
488
697
|
const it = wornItem(a, slot);
|
|
489
698
|
const up = ups.some((u) => u.slot === slot);
|
|
490
699
|
const dur = it ? durabilityText(a, slot) : '';
|
|
700
|
+
const kind = it ? [it.typeName, it.armor ? `${it.armor} armor` : '', it.weapon ? `${it.weapon.dmgMin}-${it.weapon.dmgMax}` : ''].filter(Boolean).join(' ') : '';
|
|
701
|
+
// The best piece waiting for this slot, said in the tooltip (the sheet has no upgrade rows).
|
|
702
|
+
const best = ups.filter((u) => u.slot === slot).sort((x, y) => y.delta - x.delta)[0];
|
|
703
|
+
const bestLines = best ? ['', green(`▲ Better: ${a.itemName(best.entry)} (+${Math.round(best.delta)})${best.holderId ? `, ${best.holderName} has it` : ''}`), dim('Enter: pick it u: put on every upgrade')] : [];
|
|
491
704
|
rows.push({
|
|
492
705
|
text: `${fit(SLOT_LABEL[slot], 10)} ${it ? qualityPaint(it.quality)(it.name) : dim('—')}${up ? green(' ▲') : ''}${dur ? ` ${dur}` : ''}`, data: { slot, it },
|
|
493
|
-
|
|
706
|
+
icon: it?.icon, sub: it ? dim(`${fit('', 10)} ${kind}`) : undefined,
|
|
707
|
+
cell: { slot, it, up, dur, sub: dim(kind) },
|
|
708
|
+
detail: () => (it ? itemLines(a, it, [...enchantLines(a, a.ch, slot), '', ...(dur ? [`Durability ${dur}`] : []), dim('Enter: change x: take off'), ...slotRepairLines(a, slot), ...bestLines]) : [bold(SLOT_LABEL[slot]), dim('Nothing worn here.'), '', dim('Enter: pick something to wear'), ...bestLines]),
|
|
494
709
|
act: () => a.push(slotDialog(a, slot)),
|
|
495
710
|
});
|
|
496
711
|
}
|
|
497
|
-
rows.push(...
|
|
712
|
+
rows.push(...(sheet ? extras.filter((r) => !r.header).slice(0, sheet.extras) : extras));
|
|
498
713
|
return rows;
|
|
499
714
|
},
|
|
500
715
|
};
|
|
@@ -513,19 +728,18 @@ function slotDialog(app, slot) {
|
|
|
513
728
|
const ups = new Map(upgradeList(a).filter((u) => u.slot === slot).map((u) => [u.entry, u]));
|
|
514
729
|
const rows = [{ text: `${bold(label)}: ${worn ? qualityPaint(worn.quality)(worn.name) : dim('empty')}`, header: true }, { text: '', header: true }];
|
|
515
730
|
if (worn) rows.push({ text: dim(`Take off ${worn.name}`), act: () => { if (a.result(a.g.unequip(slot), `You take off ${worn.name}.`)) a.pop(); } });
|
|
516
|
-
const row = (s, from) => {
|
|
731
|
+
const row = (s, from, comp) => {
|
|
517
732
|
const it = ds.item(s.item);
|
|
518
733
|
const up = ups.get(s.item);
|
|
519
734
|
const low = it.requiredLevel > ch.level;
|
|
520
735
|
const name = qualityPaint(it.quality)(it.name);
|
|
521
736
|
return {
|
|
522
|
-
text:
|
|
523
|
-
data: { s, it },
|
|
524
|
-
detail: () => itemLines(a, it, [...compareLines(a, it, worn), '', dim(from === 'bank' ? 'Enter: take it out of the bank and wear it' : 'Enter: wear 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(' ')),
|
|
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')]),
|
|
525
740
|
act: () => {
|
|
526
741
|
if (from === 'bank' && !a.result(a.g.bankWithdraw(s.item, 1), '')) return;
|
|
527
|
-
|
|
528
|
-
if (a.result(r, r.ok ? `You equip ${it.name}.` : '')) a.pop();
|
|
742
|
+
if (equipFrom(a, s.item, slot, comp?.id)) a.pop();
|
|
529
743
|
},
|
|
530
744
|
};
|
|
531
745
|
};
|
|
@@ -539,30 +753,86 @@ function slotDialog(app, slot) {
|
|
|
539
753
|
if (!bank.length) rows.push({ text: dim(' Nothing in the bank fits either.'), header: true });
|
|
540
754
|
for (const s of bank) rows.push(row(s, 'bank'));
|
|
541
755
|
}
|
|
756
|
+
// Unbound pieces a companion carries (a crafter's product, a BoE they looted) can come straight over.
|
|
757
|
+
const held = ownedCompanions(a.g).flatMap((c) => (c.bags?.stacks ?? []).filter((s) => fits(s) && !stackBoundToHolder(s, ds)).map((s) => ({ s, c })));
|
|
758
|
+
if (held.length) {
|
|
759
|
+
rows.push({ text: '', header: true }, { text: `In your companions' bags (${held.length})`, header: true });
|
|
760
|
+
for (const { s, c } of held) rows.push(row(s, 'companion', c));
|
|
761
|
+
}
|
|
542
762
|
return rows;
|
|
543
763
|
},
|
|
544
764
|
};
|
|
545
765
|
}
|
|
546
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
|
+
|
|
547
792
|
function spellsView(app) {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
+
},
|
|
551
808
|
rows(a) {
|
|
552
|
-
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;
|
|
553
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)) },
|
|
554
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)) },
|
|
555
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 });
|
|
556
824
|
return [...head, ...known.map((sp) => {
|
|
557
825
|
const cd = a.g.spellCooldownRemaining(sp.id);
|
|
558
826
|
return {
|
|
559
827
|
text: `${sp.name}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''}${sp.cost ? dim(` ${sp.cost} ${sp.powerType}`) : ''}${cd > 0 ? red(` ${secondsText(cd)}`) : ''}`,
|
|
828
|
+
icon: sp.icon, sub: dim(`${sp.castTimeMs ? `${(sp.castTimeMs / 1000).toFixed(1)}s cast` : sp.channelMs ? 'channeled' : 'instant'}${sp.cooldownMs ? ` ${secondsText(sp.cooldownMs)} cooldown` : ''}`),
|
|
560
829
|
detail: () => spellLines(a, sp),
|
|
561
830
|
act: () => castWithPicker(a, sp.id),
|
|
562
831
|
};
|
|
563
832
|
})];
|
|
564
833
|
},
|
|
565
834
|
};
|
|
835
|
+
return view;
|
|
566
836
|
}
|
|
567
837
|
|
|
568
838
|
// ---- rotation ------------------------------------------------------------------------------------
|
|
@@ -575,6 +845,11 @@ function rotNames(app) {
|
|
|
575
845
|
item: (id) => app.itemName(id),
|
|
576
846
|
creature: (e) => app.ds.creature(e)?.name,
|
|
577
847
|
object: (e) => app.ds.gameObject?.(e)?.name,
|
|
848
|
+
seat: (unit) => {
|
|
849
|
+
if (unit === 'player') return app.g.state.char.name || 'You';
|
|
850
|
+
const i = allyIndexOfSide(unit);
|
|
851
|
+
return (i >= 0 ? (app.g.state.squad ?? [])[i]?.name : undefined) ?? unit;
|
|
852
|
+
},
|
|
578
853
|
};
|
|
579
854
|
}
|
|
580
855
|
|
|
@@ -587,6 +862,7 @@ function rotOwner(app) {
|
|
|
587
862
|
return {
|
|
588
863
|
name: 'your', comp: null, get: () => g.rotation(), set: (r) => g.setRotation(r), reset: () => g.resetRotation(),
|
|
589
864
|
spells: () => knownUsableSpells(app.ch, g.state.spellbook, app.ds), bag: () => g.state.inventory.stacks,
|
|
865
|
+
char: () => app.ch, spellbook: () => g.state.spellbook, equipped: () => app.ch.equipped,
|
|
590
866
|
profiles: () => g.rotationProfiles(), add: (o) => g.addRotationProfile(o), rename: (pid, n) => g.renameRotationProfile(pid, n),
|
|
591
867
|
del: (pid) => g.deleteRotationProfile(pid), select: (pid) => g.selectRotationProfile(pid),
|
|
592
868
|
};
|
|
@@ -595,6 +871,7 @@ function rotOwner(app) {
|
|
|
595
871
|
return {
|
|
596
872
|
name: `${comp.name}'s`, comp, get: () => g.companionRotation(comp.id), set: (r) => g.setCompanionRotation(comp.id, r), reset: () => g.resetCompanionRotation(comp.id),
|
|
597
873
|
spells: () => knownUsableSpells(companionCharacter(comp, lvl, app.ds), companionSpellbook(comp, lvl, app.ds), app.ds), bag: () => comp.bags?.stacks ?? [],
|
|
874
|
+
char: () => companionCharacter(comp, lvl, app.ds), spellbook: () => companionSpellbook(comp, lvl, app.ds), equipped: () => comp.equipped,
|
|
598
875
|
profiles: () => g.companionRotationProfiles(comp.id), add: (o) => g.addCompanionRotationProfile(comp.id, o), rename: (pid, n) => g.renameCompanionRotationProfile(comp.id, pid, n),
|
|
599
876
|
del: (pid) => g.deleteCompanionRotationProfile(comp.id, pid), select: (pid) => g.selectCompanionRotationProfile(comp.id, pid),
|
|
600
877
|
};
|
|
@@ -645,8 +922,10 @@ function profileMenu(app) {
|
|
|
645
922
|
|
|
646
923
|
function rotationView(app) {
|
|
647
924
|
const rebuild = (a) => a.confirm('Rebuild the recommended rotation? The edits are lost.', () => { rotOwner(a).reset(); a.say(green('Rotation rebuilt.')); });
|
|
648
|
-
|
|
925
|
+
const view = {
|
|
649
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(),
|
|
650
929
|
keys: {
|
|
651
930
|
w: { hint: 'whose', fn: (a) => pickRotationOwner(a) },
|
|
652
931
|
p: { hint: 'profiles', fn: (a) => profileMenu(a) },
|
|
@@ -660,6 +939,7 @@ function rotationView(app) {
|
|
|
660
939
|
d: { fn: (a, row) => row?.data?.rule && a.confirm('Delete this rule?', () => deleteRule(a, row.data)) },
|
|
661
940
|
s: { hint: 'spec', fn: (a) => { if (!rotOwner(a).comp) pickSpec(a); } },
|
|
662
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)); } },
|
|
663
943
|
},
|
|
664
944
|
rows(a) {
|
|
665
945
|
const g = a.g, o = rotOwner(a), rot = o.get(), names = rotNames(a);
|
|
@@ -669,23 +949,37 @@ function rotationView(app) {
|
|
|
669
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 },
|
|
670
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) }] : []),
|
|
671
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) },
|
|
672
|
-
...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 })),
|
|
673
953
|
];
|
|
674
954
|
for (const phase of PHASES) {
|
|
675
955
|
const rules = rot.phases[phase] ?? [];
|
|
676
956
|
if (!rules.length && phase === 'prebuff') continue;
|
|
677
|
-
|
|
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;
|
|
678
968
|
rules.forEach((rule, index) => {
|
|
679
969
|
const conds = conditionsSentence(rule.conditions, names);
|
|
680
|
-
|
|
970
|
+
// With icons a rule is two lines and the condition takes the second; without, it trails the sentence.
|
|
971
|
+
const icon = iconsOn() ? a.ds.spell(rule.action?.spellId ?? rule.before?.spellId)?.icon : undefined;
|
|
972
|
+
const when = conds !== 'always' ? dim(`when ${conds}`) : '';
|
|
973
|
+
const text = ` ${rule.enabled ? green('✓') : dim('·')} ${rule.enabled ? ruleSentence(rule, names) : dim(ruleSentence(rule, names))}${!icon && when ? ` ${when}` : ''}`;
|
|
681
974
|
rows.push({
|
|
682
975
|
text, data: { phase, index, rule },
|
|
976
|
+
icon, sub: when,
|
|
683
977
|
detail: () => ruleLines(a, rule, names),
|
|
684
978
|
act: () => ruleMenu(a, { phase, index, rule }),
|
|
685
979
|
});
|
|
686
980
|
});
|
|
687
981
|
rows.push({
|
|
688
|
-
text: dim(
|
|
982
|
+
text: dim(` + Add a rule to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}`), data: { phase },
|
|
689
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.')],
|
|
690
984
|
act: () => addRule(a, phase),
|
|
691
985
|
});
|
|
@@ -704,6 +998,7 @@ function rotationView(app) {
|
|
|
704
998
|
return rows;
|
|
705
999
|
},
|
|
706
1000
|
};
|
|
1001
|
+
return view;
|
|
707
1002
|
}
|
|
708
1003
|
|
|
709
1004
|
/** Enter on a rule: everything you can do with it, in one list. */
|
|
@@ -712,7 +1007,7 @@ function ruleMenu(app, data) {
|
|
|
712
1007
|
const names = rotNames(app);
|
|
713
1008
|
const options = [
|
|
714
1009
|
{ label: rule.enabled ? 'Turn it off' : green('Turn it on'), detail: [rule.enabled ? 'Auto-play skips a rule that is off.' : 'Auto-play considers this rule again.'], value: 'toggle' },
|
|
715
|
-
{ label: 'Change when it fires', detail: ['
|
|
1010
|
+
{ label: 'Change when it fires', detail: ['Every condition the game knows: health and power, auras and stacks, casting, crowd control, enemy and ally counts, distances, battleground and raid state. Combine them with and / or.', '', dim(`Now: ${conditionsSentence(rule.conditions, names)}`)], value: 'cond' },
|
|
716
1011
|
{ label: 'Move up', detail: ['Earlier rules are tried first.'], value: 'up' },
|
|
717
1012
|
{ label: 'Move down', detail: ['Later rules only fire when everything above them is unusable or gated off.'], value: 'down' },
|
|
718
1013
|
{ label: 'Add a rule after this one', value: 'add' },
|
|
@@ -766,10 +1061,21 @@ function addRule(app, phase, afterIndex) {
|
|
|
766
1061
|
const options = known.map((sp) => ({ label: `${sp.name}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''}`, detail: () => spellLines(app, sp), value: { kind: 'spell', spellId: sp.id } }));
|
|
767
1062
|
const combatItems = [...new Set(o.bag().map((s) => s.item))].map((e) => app.ds.item(e)).filter((it) => it?.useInCombat);
|
|
768
1063
|
for (const it of combatItems) options.push({ label: `Use ${qualityPaint(it.quality)(it.name)}`, detail: () => itemLines(app, it), value: { kind: 'item', itemEntry: it.entry } });
|
|
769
|
-
app.pick(`Add to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}: what to do`, options, (
|
|
1064
|
+
app.pick(`Add to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}: what to do`, options, (picked) => {
|
|
770
1065
|
const id = `tui-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
|
|
1066
|
+
// A new rule starts with the engine's smart default for what it does: a seal or buff only when it is
|
|
1067
|
+
// missing, a DoT when the enemy lacks it, a form when not in it, an interrupt when the enemy casts, a
|
|
1068
|
+
// potion below a health band. The same gate the recommended list would give it; c changes it.
|
|
1069
|
+
let action = picked, conditions = [];
|
|
1070
|
+
if (picked.kind === 'spell') {
|
|
1071
|
+
const sp = app.ds.spell(picked.spellId);
|
|
1072
|
+
if (sp) conditions = defaultConditionsForSpell(sp, o.char());
|
|
1073
|
+
} else if (picked.kind === 'item') {
|
|
1074
|
+
({ action, conditions } = defaultItemRule(app, o, picked.itemEntry));
|
|
1075
|
+
}
|
|
1076
|
+
app.tabs[5].view.open.add(phase);
|
|
771
1077
|
editRotation(app, (rot) => {
|
|
772
|
-
const rule = { id, action, conditions
|
|
1078
|
+
const rule = { id, action, conditions, enabled: true };
|
|
773
1079
|
const list = rot.phases[phase] ??= [];
|
|
774
1080
|
list.splice(afterIndex == null ? list.length : afterIndex + 1, 0, rule);
|
|
775
1081
|
});
|
|
@@ -777,39 +1083,14 @@ function addRule(app, phase, afterIndex) {
|
|
|
777
1083
|
const view = app.tabs[5].view;
|
|
778
1084
|
const at = app.rowsOf(view).findIndex((r) => r.data?.rule?.id === id);
|
|
779
1085
|
if (at >= 0) { view.cursor = at; app.ensureVisible(view, app.rowsOf(view)); }
|
|
780
|
-
|
|
1086
|
+
const when = conditions.length ? `only when ${conditionsSentence(conditions, rotNames(app))}` : 'with no conditions';
|
|
1087
|
+
app.say(green(`Rule added to ${phase}, ${when}. Enter on it to change when it fires.`));
|
|
781
1088
|
});
|
|
782
1089
|
}
|
|
783
1090
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
const presets = [
|
|
788
|
-
{ label: 'Always', value: [] },
|
|
789
|
-
{ label: 'My health is below …%', value: 'hp', prompt: { metric: 'healthPct', subject: 'self', op: '<' } },
|
|
790
|
-
{ label: 'My mana/power is above …%', value: 'pw', prompt: { metric: 'powerPct', subject: 'self', op: '>' } },
|
|
791
|
-
{ label: "The enemy's health is below …%", value: 'ehp', prompt: { metric: 'healthPct', subject: 'target', op: '<' } },
|
|
792
|
-
{ label: "The enemy's health is above …%", value: 'ehp2', prompt: { metric: 'healthPct', subject: 'target', op: '>' } },
|
|
793
|
-
...(spellRef ? [
|
|
794
|
-
{ label: `The enemy does not have ${spellName}`, value: [{ subject: 'target', metric: 'debuffPresent', op: '==', value: 0, ref: spellRef }] },
|
|
795
|
-
{ label: `I do not have ${spellName}`, value: [{ subject: 'self', metric: 'buffPresent', op: '==', value: 0, ref: spellRef }] },
|
|
796
|
-
{ label: `${spellName} has under 3 s left on the enemy`, value: [{ subject: 'target', metric: 'debuffRemainingMs', op: '<', value: 3000, ref: spellRef }] },
|
|
797
|
-
] : []),
|
|
798
|
-
{ label: 'The enemy is casting', value: [{ subject: 'target', metric: 'castingPresent', op: '==', value: 1 }] },
|
|
799
|
-
{ label: 'More than one living enemy', value: [{ subject: 'self', metric: 'enemyCount', op: '>', value: 1 }] },
|
|
800
|
-
{ label: 'I am in melee range', value: [{ subject: 'self', metric: 'inMelee', op: '==', value: 1 }] },
|
|
801
|
-
{ label: 'I am at range', value: [{ subject: 'self', metric: 'inMelee', op: '==', value: 0 }] },
|
|
802
|
-
];
|
|
803
|
-
const apply = (conds) => editRotation(app, (rot) => { rot.phases[phase][index].conditions = conds; });
|
|
804
|
-
app.pick(`${spellName}: fire only when…`, presets.map((p) => ({ label: p.label, value: p })), (p) => {
|
|
805
|
-
if (Array.isArray(p.value)) { apply(p.value); return; }
|
|
806
|
-
app.prompt('Percent: ', (txt) => {
|
|
807
|
-
const n = Number(txt);
|
|
808
|
-
if (!(n >= 0 && n <= 100)) { app.flashMsg('Enter a number from 0 to 100.'); return; }
|
|
809
|
-
apply([{ ...p.prompt, value: n }]);
|
|
810
|
-
app.afterAction();
|
|
811
|
-
});
|
|
812
|
-
});
|
|
1091
|
+
/** Enter on a rule, "Change when it fires": the full condition editor (tuiRotationCond.mjs). */
|
|
1092
|
+
function editCondition(app, { phase, index }) {
|
|
1093
|
+
conditionEditor(app, { phase, index, owner: rotOwner(app), names: rotNames(app), mutate: (fn) => editRotation(app, fn) });
|
|
813
1094
|
}
|
|
814
1095
|
|
|
815
1096
|
function pickSpec(app) {
|
|
@@ -823,14 +1104,27 @@ function pickSpec(app) {
|
|
|
823
1104
|
function talentsView(app) {
|
|
824
1105
|
return {
|
|
825
1106
|
id: 'talents', title: 'Talents', hint: 'Enter: spend a point',
|
|
1107
|
+
// Drawn as the three trees (tuiTalentTree.mjs) when colour is on and they fit; a narrower tooltip makes room.
|
|
1108
|
+
detailShare: 0.34,
|
|
1109
|
+
grid: (a, rows, w, h) => talentGrid(a, rows, w, h),
|
|
826
1110
|
keys: {
|
|
827
1111
|
u: { hint: 'refund', fn: (a, row) => row?.data && a.result(a.g.refundTalent(row.data.id), `Refunded a point from ${row.data.name}.`) },
|
|
828
|
-
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.')); } },
|
|
829
1113
|
B: { hint: 'builds', fn: (a) => talentBuildsMenu(a) },
|
|
830
1114
|
},
|
|
831
1115
|
rows(a) {
|
|
832
1116
|
const ch = a.ch;
|
|
833
1117
|
const free = availablePoints(ch);
|
|
1118
|
+
if (talentPlan(a)) {
|
|
1119
|
+
return a.ds.talentTrees(ch.cls).flatMap((tree, k) => [...tree.talents].sort((x, y) => x.tier - y.tier || x.col - y.col).map((t) => {
|
|
1120
|
+
const rank = ch.talents?.[t.id] ?? 0;
|
|
1121
|
+
return {
|
|
1122
|
+
text: t.name, data: t, node: { tree: k, t, rank, tone: talentTone(ch, t, a.ds), icon: talentIcon(a.ds, t) },
|
|
1123
|
+
detail: () => talentLines(a, t, rank, canSpend(ch, t.id, a.ds)),
|
|
1124
|
+
act: () => a.result(a.g.spendTalent(t.id), `You learn ${t.name} (${(ch.talents?.[t.id] ?? 0) + 1}/${t.maxRank}).`),
|
|
1125
|
+
};
|
|
1126
|
+
}));
|
|
1127
|
+
}
|
|
834
1128
|
const rows = [{ text: ch.level < 10 ? dim('Talents unlock at level 10.') : `${plural(free, 'point')} to spend`, header: true }];
|
|
835
1129
|
if (ch.level >= 10) rows.push({ text: `Builds ${dim(`premade specs, saved loadouts, talent strings ${myTalentLink(a) || 'nothing spent yet'}`)}`, detail: () => [bold('Talent builds'), '', 'Switch to a premade spec, a build you saved, or a pasted talent string. Spent points cost a respec.', '', dim('Enter (or B): open')], act: () => talentBuildsMenu(a) });
|
|
836
1130
|
for (const tree of a.ds.talentTrees(ch.cls)) {
|
|
@@ -842,6 +1136,7 @@ function talentsView(app) {
|
|
|
842
1136
|
const paint = rank >= t.maxRank ? brightYellow : can.ok ? green : dim;
|
|
843
1137
|
rows.push({
|
|
844
1138
|
text: `${paint(t.name)} ${dim(`${rank}/${t.maxRank}`)}${t.prereq ? dim(` needs ${a.ds.talent(t.prereq.talentId)?.name ?? '?'}`) : ''}`, data: t,
|
|
1139
|
+
icon: talentIcon(a.ds, t), sub: dim(`tier ${t.tier + 1}`),
|
|
845
1140
|
detail: () => talentLines(a, t, rank, can),
|
|
846
1141
|
act: () => a.result(a.g.spendTalent(t.id), `You learn ${t.name} (${(ch.talents?.[t.id] ?? 0) + 1}/${t.maxRank}).`),
|
|
847
1142
|
});
|
|
@@ -853,9 +1148,14 @@ function talentsView(app) {
|
|
|
853
1148
|
}
|
|
854
1149
|
|
|
855
1150
|
function talentLines(app, t, rank, can) {
|
|
1151
|
+
return withIcon(talentIcon(app.ds, t), talentLinesText(app, t, rank, can));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function talentLinesText(app, t, rank, can) {
|
|
856
1155
|
const out = [bold(t.name) + dim(` tier ${t.tier + 1}, rank ${rank}/${t.maxRank}`)];
|
|
857
|
-
|
|
858
|
-
if (rank
|
|
1156
|
+
// The pane wraps the descriptions to its own width (beside the icon while they share its rows).
|
|
1157
|
+
if (rank > 0) out.push('', cyan('Current rank'), t.ranks[rank - 1]?.description ?? '');
|
|
1158
|
+
if (rank < t.maxRank) out.push('', cyan(rank ? 'Next rank' : 'Rank 1'), t.ranks[rank]?.description ?? '');
|
|
859
1159
|
out.push('', can.ok ? green('Enter to spend a point') : red(can.reason ?? 'Cannot spend here yet'));
|
|
860
1160
|
if (rank > 0 && canRefund(app.ch, t.id, app.ds).ok) out.push(dim('u refunds a point'));
|
|
861
1161
|
return out;
|
|
@@ -866,27 +1166,40 @@ function talentLines(app, t, rank, can) {
|
|
|
866
1166
|
function travelView(app) {
|
|
867
1167
|
return {
|
|
868
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.')], {}); },
|
|
869
1171
|
rows(a) {
|
|
870
1172
|
const g = a.g, ds = a.ds, z = a.zone(), ch = a.ch;
|
|
871
|
-
const rows = [...journeyRows(a), { text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }
|
|
872
|
-
|
|
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
|
+
}
|
|
873
1186
|
const taxi = g.taxiOptions();
|
|
874
1187
|
if (taxi.length) {
|
|
875
1188
|
rows.push({ text: '', header: true }, { text: 'Flight paths', header: true });
|
|
876
|
-
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) }); }
|
|
877
1190
|
}
|
|
878
1191
|
rows.push({ text: '', header: true }, { text: 'Hearthstone', header: true });
|
|
879
1192
|
const bind = ch.hearthBind;
|
|
880
1193
|
const cd = Math.max(0, (ch.hearthCooldownUntil ?? 0) - Date.now());
|
|
881
1194
|
rows.push({
|
|
882
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.'),
|
|
883
|
-
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 }] } : {}),
|
|
884
1197
|
act: () => { const r = g.castHearth(); if (a.result(r)) { a.vendor = null; afterDeparture(a, () => `You hearth to ${a.zone()?.name}.`); landOnTab(a); } },
|
|
885
1198
|
});
|
|
886
1199
|
const ports = g.classTravelOptions();
|
|
887
1200
|
if (ports.length) {
|
|
888
1201
|
rows.push({ text: '', header: true }, { text: 'Teleports', header: true });
|
|
889
|
-
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); } } });
|
|
890
1203
|
}
|
|
891
1204
|
rows.push(...portalRows(a));
|
|
892
1205
|
return rows;
|
|
@@ -921,9 +1234,117 @@ function weaponSkillRows(a, L) {
|
|
|
921
1234
|
return rows;
|
|
922
1235
|
}
|
|
923
1236
|
|
|
924
|
-
|
|
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
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Your character as a sprite for a pane: the form or costume you wear when the creature pack has it, else
|
|
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.
|
|
1298
|
+
*/
|
|
1299
|
+
export function playerArt(app, maxCols, maxRows, view = null) {
|
|
1300
|
+
const ch = app.ch;
|
|
1301
|
+
const worn = wornNow(app.ds, ch, view);
|
|
1302
|
+
const drawn = wornArt(worn, maxCols, maxRows);
|
|
1303
|
+
if (drawn) return { lines: drawn, label: worn.label };
|
|
1304
|
+
const geared = paperdollArt(ch, maxCols, maxRows);
|
|
1305
|
+
if (geared) return { lines: geared, label: null };
|
|
1306
|
+
const own = creatureArt(nativeDisplayId(ch.race, ch.gender), maxCols, maxRows);
|
|
1307
|
+
return own ? { lines: own, label: null } : null;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
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) {
|
|
925
1339
|
return {
|
|
926
|
-
id: 'char', title: '
|
|
1340
|
+
id: 'char', title: 'Stats', hint: 'Esc: back',
|
|
1341
|
+
/** The detail pane holds your character: the body of your race and gender, or the form you are in. */
|
|
1342
|
+
detail(a) {
|
|
1343
|
+
const L = a.layout(), ch = a.ch;
|
|
1344
|
+
const art = playerArt(a, L.detailW - 2, L.mainH - 2);
|
|
1345
|
+
if (!art) return [];
|
|
1346
|
+
return [`${bold(ch.name)} ${dim(`${raceName(ch.race)} ${className(ch.cls)}${art.label ? `, ${art.label}` : ''}`)}`, '', ...art.lines];
|
|
1347
|
+
},
|
|
927
1348
|
rows(a) {
|
|
928
1349
|
const ch = a.ch, ds = a.ds;
|
|
929
1350
|
const st = effectiveStats(ch, ds);
|
|
@@ -933,7 +1354,7 @@ function charView(app) {
|
|
|
933
1354
|
L(`${bold(ch.name)} level ${ch.level} ${className(ch.cls)}`),
|
|
934
1355
|
L(''),
|
|
935
1356
|
L(`Health ${ch.health}/${maxHealth(ch, ds)} ${pt[0].toUpperCase() + pt.slice(1)} ${ch.power}/${maxPower(ch, ds)}`),
|
|
936
|
-
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)}`),
|
|
937
1358
|
L(''),
|
|
938
1359
|
L(cyan('Attributes')),
|
|
939
1360
|
...Object.entries(STAT_LABEL).map(([k, name]) => L(` ${fit(name, 10)} ${st[k]}`)),
|
|
@@ -968,7 +1389,6 @@ function charView(app) {
|
|
|
968
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)) });
|
|
969
1390
|
rows.push(L(''), { text: `Honor ${dim(`${honorRankLine(a)} (:honor)`)}`, act: () => a.push(honorDialog(a)) });
|
|
970
1391
|
rows.push({ text: `Reputation ${dim(`${a.ch.watchedFaction ? `watching ${ds.faction(a.ch.watchedFaction)?.name ?? ''} ` : ''}(:rep)`)}`, act: () => a.push(reputationDialog(a)) });
|
|
971
|
-
rows.push({ text: `Settings ${dim('loot, upgrades, disenchanting, junk, gathering (:settings)')}`, act: () => a.push(settingsDialog(a)) });
|
|
972
1392
|
return rows;
|
|
973
1393
|
},
|
|
974
1394
|
};
|
|
@@ -989,14 +1409,14 @@ export function sellStack(app, slot) {
|
|
|
989
1409
|
const s = app.g.state.inventory.stacks[slot];
|
|
990
1410
|
if (!s) return;
|
|
991
1411
|
const r = app.g.sellItemStack(slot, s.count);
|
|
992
|
-
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);
|
|
993
1413
|
}
|
|
994
1414
|
|
|
995
1415
|
/** Sell every junk-filter match, each onto the buyback list. */
|
|
996
1416
|
export function sellJunk(app) {
|
|
997
1417
|
const preview = app.g.junkSellPreview();
|
|
998
1418
|
const r = app.g.sellAllJunk();
|
|
999
|
-
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;
|
|
1000
1420
|
for (const p of preview) noteSale(app, p.entry, p.count, p.unitSell * p.count);
|
|
1001
1421
|
}
|
|
1002
1422
|
|
|
@@ -1012,7 +1432,7 @@ function buyBackAt(app, i) {
|
|
|
1012
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));
|
|
1013
1433
|
if (!r.ok) { app.say(red(r.reason ?? 'Cannot buy that back.')); return; }
|
|
1014
1434
|
app.g.state.char = r.char; app.g.state.inventory = r.inventory; app.buyback = r.buyback;
|
|
1015
|
-
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)}.`));
|
|
1016
1436
|
}
|
|
1017
1437
|
|
|
1018
1438
|
export function vendorDialog(app, vendor) {
|
|
@@ -1028,7 +1448,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1028
1448
|
const comp = row?.data?.comp;
|
|
1029
1449
|
if (!comp) return;
|
|
1030
1450
|
const r = a.g.sellCompanionJunk(comp.id);
|
|
1031
|
-
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);
|
|
1032
1452
|
} },
|
|
1033
1453
|
d: { hint: 'destroy', fn: (a, row) => {
|
|
1034
1454
|
if (mode === 'sell' && row?.data) { const i = a.g.state.inventory.stacks.indexOf(row.data); if (i >= 0) destroyStack(a, i); return; }
|
|
@@ -1038,7 +1458,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1038
1458
|
const name = a.itemLabel(row.data.s.item);
|
|
1039
1459
|
a.confirm(`Destroy ${name} from ${comp.name}'s bags?`, () => a.result(a.g.discardCompanionBagStack(comp.id, slot), `Destroyed ${name}.`));
|
|
1040
1460
|
} },
|
|
1041
|
-
...(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.') : ''); } } } : {}),
|
|
1042
1462
|
},
|
|
1043
1463
|
onKey(a, k) {
|
|
1044
1464
|
if (k !== 'tab' && k !== 'shift-tab') return false;
|
|
@@ -1050,7 +1470,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1050
1470
|
onClose(a) { /* the vendor stays "open" for :buy / s in bags until you travel */ },
|
|
1051
1471
|
rows(a) {
|
|
1052
1472
|
const tabs = `${bold(VENDOR_MODE_LABEL[mode])} ${dim(`${VENDOR_MODES.indexOf(mode) + 1}/${VENDOR_MODES.length}`)}`;
|
|
1053
|
-
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 }];
|
|
1054
1474
|
if (mode === 'buy') {
|
|
1055
1475
|
const offers = a.g.vendorOffers(vendor.entry);
|
|
1056
1476
|
const uses = reagentUses(a);
|
|
@@ -1059,12 +1479,13 @@ export function vendorDialog(app, vendor) {
|
|
|
1059
1479
|
const it = a.ds.item(o.itemEntry);
|
|
1060
1480
|
const ok = o.affordable && o.meetsRequirements && o.inStock && o.fits;
|
|
1061
1481
|
const name = `${qualityPaint(it?.quality)(it?.name ?? o.itemEntry)}${o.unitsPerPurchase > 1 ? ` x${o.unitsPerPurchase}` : ''}`;
|
|
1062
|
-
const price = o.honorPrice ? `${o.honorPrice} honor` :
|
|
1482
|
+
const price = o.honorPrice ? `${o.honorPrice} honor` : coinText(o.pricePerPurchase);
|
|
1063
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') : '';
|
|
1064
1484
|
const usedBy = uses.get(o.itemEntry);
|
|
1065
1485
|
rows.push({
|
|
1066
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,
|
|
1067
|
-
|
|
1487
|
+
icon: it?.icon, sub: dim([it?.typeName, it?.slot].filter(Boolean).join(' · ')),
|
|
1488
|
+
detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${coinText(o.basePricePerPurchase)} before reputation)`)] : []),
|
|
1068
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.')] : [])]),
|
|
1069
1490
|
act: () => buy(a, o, 1),
|
|
1070
1491
|
});
|
|
@@ -1072,13 +1493,14 @@ export function vendorDialog(app, vendor) {
|
|
|
1072
1493
|
} else if (mode === 'sell') {
|
|
1073
1494
|
const st = a.g.state.inventory.stacks;
|
|
1074
1495
|
const junk = a.g.junkSellPreview();
|
|
1075
|
-
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 });
|
|
1076
1497
|
if (!st.length) rows.push({ text: dim('Your bags are empty.'), header: true });
|
|
1077
1498
|
st.forEach((s, slot) => {
|
|
1078
1499
|
const it = a.ds.item(s.item);
|
|
1079
1500
|
const total = (it?.sellPrice ?? 0) * s.count;
|
|
1080
1501
|
rows.push({
|
|
1081
|
-
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,
|
|
1503
|
+
icon: it?.icon, sub: dim(it?.typeName ?? ''),
|
|
1082
1504
|
detail: () => itemLines(a, it, ['', dim('Enter: sell the stack (you can buy it back on the Buyback tab) d: destroy')]),
|
|
1083
1505
|
act: () => sellStack(a, slot),
|
|
1084
1506
|
});
|
|
@@ -1089,8 +1511,8 @@ export function vendorDialog(app, vendor) {
|
|
|
1089
1511
|
list.forEach((e, i) => {
|
|
1090
1512
|
const it = a.ds.item(e.entry);
|
|
1091
1513
|
rows.push({
|
|
1092
|
-
text: `${qualityPaint(it?.quality)(it?.name ?? e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} ${
|
|
1093
|
-
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)}`]),
|
|
1094
1516
|
act: () => buyBackAt(a, i),
|
|
1095
1517
|
});
|
|
1096
1518
|
});
|
|
@@ -1105,9 +1527,9 @@ export function vendorDialog(app, vendor) {
|
|
|
1105
1527
|
bags.forEach((s, slot) => {
|
|
1106
1528
|
const it = a.ds.item(s.item);
|
|
1107
1529
|
rows.push({
|
|
1108
|
-
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 },
|
|
1109
1531
|
detail: () => itemLines(a, it, ['', dim(`Enter: sell it out of ${comp.name}'s bags j: sell their junk d: destroy`)]),
|
|
1110
|
-
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); },
|
|
1111
1533
|
});
|
|
1112
1534
|
});
|
|
1113
1535
|
}
|
|
@@ -1120,34 +1542,71 @@ export function vendorDialog(app, vendor) {
|
|
|
1120
1542
|
|
|
1121
1543
|
function buy(app, offer, n) {
|
|
1122
1544
|
const r = app.g.buyItem(app.vendor.entry, offer.itemEntry, n);
|
|
1123
|
-
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)}.` : '');
|
|
1124
1546
|
}
|
|
1125
1547
|
|
|
1126
1548
|
export function trainerDialog(app, trainer) {
|
|
1127
1549
|
const known = () => new Set(app.g.state.spellbook.known);
|
|
1550
|
+
// Weapon masters list every class's weapons; hide the rows the class/race gate would refuse.
|
|
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
|
+
};
|
|
1128
1565
|
const learnable = () => trainer.spells
|
|
1129
|
-
.filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell))
|
|
1566
|
+
.filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts) && !needsTalent(ts))
|
|
1130
1567
|
.map((ts) => ({ ts, sp: app.ds.spell(ts.spell) })).filter((x) => x.sp)
|
|
1131
1568
|
.sort((x, y) => x.ts.reqLevel - y.ts.reqLevel || x.sp.name.localeCompare(y.sp.name));
|
|
1132
1569
|
return {
|
|
1133
1570
|
id: `trainer-${trainer.entry}`, title: trainer.name, hint: 'Enter: learn',
|
|
1134
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.'); } } },
|
|
1135
1572
|
rows(a) {
|
|
1136
|
-
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 }];
|
|
1137
1574
|
const now = learnable();
|
|
1138
1575
|
if (!now.length) rows.push({ text: dim(' Nothing new at your level.'), header: true });
|
|
1139
1576
|
for (const { sp, ts } of now) {
|
|
1140
1577
|
const can = ts.cost <= a.ch.gold;
|
|
1141
1578
|
rows.push({
|
|
1142
|
-
text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${
|
|
1143
|
-
|
|
1579
|
+
text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${coinText(ts.cost, 'free')}`, data: sp,
|
|
1580
|
+
icon: sp.icon, sub: dim(`level ${ts.reqLevel}${sp.cost ? ` ${sp.cost} ${sp.powerType}` : ''}`),
|
|
1581
|
+
detail: () => spellLines(a, sp, ['', `Cost: ${coinText(ts.cost, 'free')}`]),
|
|
1144
1582
|
act: () => a.result(a.g.learnFromTrainer(trainer.entry, sp.id), `You have learned ${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''}.`),
|
|
1145
1583
|
});
|
|
1146
1584
|
}
|
|
1147
|
-
const later = trainer.spells.filter((ts) => ts.reqLevel > a.ch.level && !known().has(ts.spell)).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);
|
|
1148
1586
|
if (later.length) {
|
|
1149
1587
|
rows.push({ text: '', header: true }, { text: 'Coming up', header: true });
|
|
1150
|
-
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
|
+
}
|
|
1151
1610
|
}
|
|
1152
1611
|
return rows;
|
|
1153
1612
|
},
|
|
@@ -1158,11 +1617,13 @@ export function innDialog(app, inn) {
|
|
|
1158
1617
|
return {
|
|
1159
1618
|
id: `inn-${inn.entry}`, title: inn.name, hint: 'Enter: choose', noDetail: true,
|
|
1160
1619
|
rows(a) {
|
|
1161
|
-
const bound = a.ch.hearthBind?.name === inn.name;
|
|
1620
|
+
const bound = a.ch.hearthBind?.name === inn.name, wares = a.ds.vendor(inn.entry);
|
|
1162
1621
|
return [
|
|
1163
|
-
{ text: `${bold(inn.name)} ${dim('<Innkeeper>')}`, header: true },
|
|
1622
|
+
{ text: `${bold(inn.name)} ${dim('<Innkeeper>')}`, header: true },
|
|
1623
|
+
// Rested xp needs no button: it builds here in game, and at the full offline rate after logging out here.
|
|
1624
|
+
{ text: dim(`${a.ch.restedXp ? `You are rested (${a.ch.restedXp} xp).` : 'You are not rested.'} Staying here builds rest, logging out here builds it faster.`), header: true }, { text: '', header: true },
|
|
1625
|
+
...(wares ? [{ text: 'Browse wares', act: () => a.push(vendorDialog(a, wares)) }] : []),
|
|
1164
1626
|
{ text: bound ? dim('This inn is already your home.') : 'Make this inn your home', act: () => { if (!bound) a.result(a.g.bindHearth(), `${inn.name} sets your hearthstone to ${a.zone()?.name}.`); } },
|
|
1165
|
-
{ text: 'Rest here for a while (60 s)', act: () => { a.idleTick(60, true); a.say(dim(`You rest at the inn. HP ${a.hpText()} ${a.pwText()}`)); } },
|
|
1166
1627
|
{ text: dim('Goodbye'), act: () => a.pop() },
|
|
1167
1628
|
];
|
|
1168
1629
|
},
|