azeroth-shell 0.1.1 → 0.2.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 +326 -82
- package/bin/azsh.mjs +21 -19
- package/package.json +19 -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/fileRepository.mjs +46 -1
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +135 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +295 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +36 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +307 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +5 -3
- package/src/cli/tui.mjs +252 -98
- package/src/cli/tuiAutopilot.mjs +347 -0
- package/src/cli/tuiBg.mjs +371 -42
- package/src/cli/tuiCheck.mjs +415 -28
- package/src/cli/tuiCity.mjs +118 -13
- package/src/cli/tuiCompanions.mjs +25 -15
- package/src/cli/tuiCreate.mjs +223 -0
- package/src/cli/tuiDungeon.mjs +1 -1
- package/src/cli/tuiFight.mjs +425 -88
- package/src/cli/tuiFightPanes.mjs +27 -8
- package/src/cli/tuiFloor.mjs +2 -2
- package/src/cli/tuiGear.mjs +10 -6
- package/src/cli/tuiLoot.mjs +1 -0
- package/src/cli/tuiPaperdoll.mjs +107 -0
- package/src/cli/tuiPartyStage.mjs +177 -0
- package/src/cli/tuiProfessions.mjs +4 -2
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +22 -3
- package/src/cli/tuiSearch.mjs +75 -22
- package/src/cli/tuiSettings.mjs +15 -0
- package/src/cli/tuiSimLab.mjs +1 -1
- package/src/cli/tuiStage.mjs +304 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiViews.mjs +296 -115
- package/src/cli/tuiWorld.mjs +4 -5
- 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.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- 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/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 +39 -1
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +7 -1
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/quests/quests.ts +60 -0
package/src/cli/tuiViews.mjs
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
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 } 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';
|
|
6
|
+
import { className, raceName } from '../domain/core/ids.ts';
|
|
7
|
+
import { nativeDisplayId, wornDisplay } from '../domain/character/displayForm.ts';
|
|
7
8
|
import { moneyText } from '../domain/economy/money.ts';
|
|
8
9
|
import { maxHealth, maxPower } from '../domain/character/character.ts';
|
|
9
10
|
import { effectiveStats, effectiveArmor, attackPower, powerType } from '../domain/character/characterStats.ts';
|
|
10
11
|
import { critChanceDisplay, dodgeChanceDisplay, hitChance, weaponSkillIdForHand, skillBonusFor } from '../domain/character/characterRatings.ts';
|
|
11
|
-
import { COMBAT_SKILL, isLevelRangeCombatSkill, combatSkillDisplayName } from '../domain/character/combatSkills.ts';
|
|
12
|
+
import { COMBAT_SKILL, isLevelRangeCombatSkill, combatSkillDisplayName, isSpellFitByClassAndRace } from '../domain/character/combatSkills.ts';
|
|
12
13
|
import { skillMaxForLevel } from '../engine/combat/mitigation.ts';
|
|
13
14
|
import { knownUsableSpells } from '../domain/character/characterSpells.ts';
|
|
14
15
|
import { bagCapacity, EQUIP_SLOTS, BAG_SLOTS, SLOT_LABEL, itemSlotTargets, DUAL_WIELD_SPELL_ID } from '../domain/items/equipment.ts';
|
|
15
16
|
import { equippedEntry } from '../domain/items/itemInstance.ts';
|
|
16
|
-
import {
|
|
17
|
+
import { stackBoundToHolder } from '../domain/raid/partyVault.ts';
|
|
18
|
+
import { knows, learnGrantIds } from '../domain/character/spellbook.ts';
|
|
17
19
|
import { availablePoints, canSpend, canRefund, spentInTree } from '../domain/character/talents.ts';
|
|
18
20
|
import { offeredQuests, chainFollowUpOffer, grayLogQuests } from '../quests/quests.ts';
|
|
19
21
|
import { isRepeatableQuest, questRepeatsAvailable } from '../domain/quests/questlog.ts';
|
|
@@ -27,8 +29,10 @@ import { squadJoinsPull } from './squad.mjs';
|
|
|
27
29
|
import { barsDialog, playerBarsOwner } from './tuiBars.mjs';
|
|
28
30
|
import { petWorldRows, petDialog, hasPetClass } from './tuiPet.mjs';
|
|
29
31
|
import { portalRows } from './tuiDungeon.mjs';
|
|
32
|
+
import { conditionEditor, defaultConditionsForSpell, defaultItemRule } from './tuiRotationCond.mjs';
|
|
33
|
+
import { allyIndexOfSide } from '../engine/core/events.ts';
|
|
30
34
|
import { bulkDisenchantDialog, learnAllDialog } from './tuiBulk.mjs';
|
|
31
|
-
import { gearExtraRows, durabilityText, repairOne, slotRepairLines, enchantLines } from './tuiGear.mjs';
|
|
35
|
+
import { gearExtraRows, durabilityText, repairOne, repairAll, slotRepairLines, enchantLines } from './tuiGear.mjs';
|
|
32
36
|
import { outfitterDialog } from './tuiOutfitter.mjs';
|
|
33
37
|
import { castWithPicker } from './tuiEnchantTarget.mjs';
|
|
34
38
|
import { settingsDialog } from './tuiSettings.mjs';
|
|
@@ -37,6 +41,12 @@ import { pickPocket, pickPocketTag, journeyRows, afterDeparture, ridingRows, obj
|
|
|
37
41
|
import { talentBuildsMenu, myTalentLink } from './tuiTalents.mjs';
|
|
38
42
|
import { simLabDialog } from './tuiSimLab.mjs';
|
|
39
43
|
import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
|
|
44
|
+
import { preflightCard } from './tuiAutopilot.mjs';
|
|
45
|
+
import { creatureArt } from './modelArt.mjs';
|
|
46
|
+
import { withIcon, iconsOn } from './iconArt.mjs';
|
|
47
|
+
import { paperdollGrid, paperdollPlan } from './tuiPaperdoll.mjs';
|
|
48
|
+
import { talentGrid, talentPlan, talentTone, talentIcon } from './tuiTalentTree.mjs';
|
|
49
|
+
import { paperdollArt } from './paperdoll.mjs';
|
|
40
50
|
import { reputationDialog } from './tuiRep.mjs';
|
|
41
51
|
import { honorDialog, honorRankLine } from './tuiHonor.mjs';
|
|
42
52
|
import { openWorldSearch } from './tuiSearch.mjs';
|
|
@@ -56,7 +66,24 @@ export function conPaint(playerLevel, level) {
|
|
|
56
66
|
|
|
57
67
|
// ---- detail panes --------------------------------------------------------------------------------
|
|
58
68
|
|
|
69
|
+
// A spell's tooltip text with the tokens the bake left in filled from its own effects: $v max target
|
|
70
|
+
// level, $q misc value (Resurrection's mana), $f damage multiplier (Ferocious Bite). A leading id reads
|
|
71
|
+
// another spell's effect ($3026q1). Anything else stays as written.
|
|
72
|
+
export function spellText(ds, sp) {
|
|
73
|
+
const text = sp?.description?.trim();
|
|
74
|
+
if (!text?.includes('$')) return text;
|
|
75
|
+
const fx = (id, i) => (id ? ds.spell(Number(id)) : sp)?.rawEffects?.[(Number(i) || 1) - 1];
|
|
76
|
+
return text
|
|
77
|
+
.replace(/\$v\b/g, (m) => sp.maxTargetLevel != null ? String(sp.maxTargetLevel) : m)
|
|
78
|
+
.replace(/\$(\d*)q(\d?)/g, (m, id, i) => fx(id, i)?.miscValue != null ? String(fx(id, i).miscValue) : m)
|
|
79
|
+
.replace(/\$(\d*)f(\d)/g, (m, id, i) => fx(id, i)?.dmgMultiplier != null ? String(fx(id, i).dmgMultiplier) : m);
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
export function itemLines(app, item, extra = []) {
|
|
83
|
+
return withIcon(item?.icon, itemLinesText(app, item, extra));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function itemLinesText(app, item, extra = []) {
|
|
60
87
|
if (!item) return ['Unknown item'];
|
|
61
88
|
const out = [qualityPaint(item.quality)(bold(item.name))];
|
|
62
89
|
const kind = [item.slot, item.typeName].filter(Boolean).join(' · ');
|
|
@@ -68,12 +95,37 @@ export function itemLines(app, item, extra = []) {
|
|
|
68
95
|
if (item.armor) out.push(`${item.armor} Armor`);
|
|
69
96
|
if (item.block) out.push(`${item.block} Block`);
|
|
70
97
|
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
|
-
|
|
98
|
+
for (const id of new Set(item.equipSpells ?? [])) {
|
|
99
|
+
const text = spellText(app.ds, app.ds.spell(id));
|
|
100
|
+
if (text) out.push(green(`Equip: ${text}`));
|
|
101
|
+
}
|
|
102
|
+
// ITEM_SPELLTRIGGER_CHANCE_ON_HIT 2.
|
|
103
|
+
for (const p of item.procs ?? []) {
|
|
104
|
+
const text = p.trigger === 2 && spellText(app.ds, app.ds.spell(p.spell));
|
|
105
|
+
if (text) out.push(green(`Chance on hit: ${text}`));
|
|
106
|
+
}
|
|
107
|
+
// The use spell's own tooltip text carries the Well Fed rider; a few still hold unresolved $ tokens.
|
|
108
|
+
const useText = spellText(app.ds, app.ds.spell(item.useSpell ?? item.useInCombat?.spellId));
|
|
109
|
+
if (useText && !useText.includes('$')) out.push(green(`Use: ${useText}`));
|
|
110
|
+
else {
|
|
111
|
+
// Same tick count rest.ts heals for: floor(duration / tick).
|
|
112
|
+
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.`));
|
|
113
|
+
if (item.useInCombat?.effect === 'restore_health') out.push(green(`Use: Restores ${item.useInCombat.min} to ${item.useInCombat.max} health.`));
|
|
114
|
+
if (item.useInCombat?.effect === 'restore_power') out.push(green(`Use: Restores ${item.useInCombat.min} to ${item.useInCombat.max} mana.`));
|
|
115
|
+
}
|
|
74
116
|
if (item.requiredLevel > 1) out.push(`Requires Level ${item.requiredLevel}`);
|
|
75
117
|
if (item.allowClass?.length) out.push(`Classes: ${item.allowClass.map(className).join(', ')}`);
|
|
76
118
|
if (item.bagSlots) out.push(`${item.bagSlots} Slot Bag`);
|
|
119
|
+
const set = item.setId && app.ds.itemSet(item.setId);
|
|
120
|
+
if (set) {
|
|
121
|
+
const worn = new Set(Object.values(app.ch?.equipped ?? {}).map(equippedEntry));
|
|
122
|
+
const have = set.items.filter((e) => worn.has(e)).length;
|
|
123
|
+
out.push('', yellow(`${set.name ?? 'Item set'} (${have}/${set.items.length})`));
|
|
124
|
+
for (const b of set.bonuses) {
|
|
125
|
+
const text = spellText(app.ds, app.ds.spell(b.spell));
|
|
126
|
+
if (text) out.push((have >= b.pieces ? green : gray)(`(${b.pieces}) Set: ${text}`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
77
129
|
if (item.description) out.push(yellow(`"${item.description}"`));
|
|
78
130
|
if (item.sellPrice) out.push(dim(`Sells for ${moneyText(item.sellPrice)}`));
|
|
79
131
|
out.push(...extra);
|
|
@@ -81,6 +133,10 @@ export function itemLines(app, item, extra = []) {
|
|
|
81
133
|
}
|
|
82
134
|
|
|
83
135
|
export function spellLines(app, sp, extra = []) {
|
|
136
|
+
return withIcon(sp?.icon, spellLinesText(app, sp, extra));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function spellLinesText(app, sp, extra = []) {
|
|
84
140
|
if (!sp) return ['Unknown spell'];
|
|
85
141
|
const out = [bold(sp.name) + (sp.rank ? dim(` Rank ${sp.rank}`) : '')];
|
|
86
142
|
const bits = [];
|
|
@@ -90,7 +146,8 @@ export function spellLines(app, sp, extra = []) {
|
|
|
90
146
|
if (sp.rangeMax) bits.push(`${sp.rangeMax} yd range`);
|
|
91
147
|
out.push(dim(bits.join(' ')));
|
|
92
148
|
if (sp.reagents?.length) out.push(dim('Reagents: ' + sp.reagents.map((r) => `${app.itemLabel(r.item)}${r.count > 1 ? ` x${r.count}` : ''}`).join(', ')));
|
|
93
|
-
|
|
149
|
+
const text = spellText(app.ds, sp);
|
|
150
|
+
if (text) out.push(yellow(text)); // the pane wraps it, beside the icon while they share its rows
|
|
94
151
|
const cd = app.g.spellCooldownRemaining(sp.id);
|
|
95
152
|
if (cd > 0) out.push(red(`Cooldown: ${secondsText(cd)} left`));
|
|
96
153
|
out.push(...extra);
|
|
@@ -113,12 +170,25 @@ export function creatureLines(app, c) {
|
|
|
113
170
|
out.push(...wanted);
|
|
114
171
|
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
172
|
out.push('', dim('Enter: fight live f: auto-resolve'));
|
|
173
|
+
// The creature itself under the text, as large as the pane has room for (modelArt.mjs).
|
|
174
|
+
const L = app.layout?.();
|
|
175
|
+
const art = L && c.displayId ? creatureArt(c.displayId, L.detailW - 2, L.mainH - out.length - 1) : null;
|
|
176
|
+
if (art) out.push('', ...art);
|
|
116
177
|
return out;
|
|
117
178
|
}
|
|
118
179
|
|
|
180
|
+
/** A person in the world: the text, then the NPC's creature drawn under it as large as the pane has room for. */
|
|
181
|
+
export function npcLines(app, entry, text) {
|
|
182
|
+
const L = app.layout?.(), id = app.ds.creature(entry)?.displayId;
|
|
183
|
+
if (!L || !id) return text;
|
|
184
|
+
const w = L.detailW - 2, used = text.reduce((n, line) => n + (vis(line) > w ? wrapLines(line, w).length : 1), 0);
|
|
185
|
+
const art = creatureArt(id, w, L.mainH - used - 1);
|
|
186
|
+
return art ? [...text, '', ...art] : text;
|
|
187
|
+
}
|
|
188
|
+
|
|
119
189
|
export function questLines(app, q, qs) {
|
|
120
190
|
const ch = app.ch;
|
|
121
|
-
const out = [bold(q.title) + dim(` level ${q.questLevel}${q.minLevel > ch.level ? red(` requires ${q.minLevel}`) : ''}`)];
|
|
191
|
+
const out = [conPaint(ch.level, q.questLevel)(bold(q.title)) + dim(` level ${q.questLevel}${q.minLevel > ch.level ? red(` requires ${q.minLevel}`) : ''}`)];
|
|
122
192
|
if (qs) out.push(qs.status === 'complete' ? green('Ready to turn in') : qs.status === 'failed' ? red('Failed') : yellow('In progress'));
|
|
123
193
|
out.push(...objectiveLines(app, q, qs).map((o) => ` ${o}`));
|
|
124
194
|
const rew = rewardLines(app, q);
|
|
@@ -142,7 +212,7 @@ export function objectiveLines(app, q, qs) {
|
|
|
142
212
|
|
|
143
213
|
export function rewardLines(app, q) {
|
|
144
214
|
const out = [];
|
|
145
|
-
if (q.rewXP) out.push(`${q.rewXP} experience`);
|
|
215
|
+
if (q.rewXP) out.push(magenta(`${q.rewXP} experience`));
|
|
146
216
|
if (q.rewMoney) out.push(yellow(moneyText(q.rewMoney)));
|
|
147
217
|
for (const it of q.rewItems) out.push(`${app.itemLabel(it.item)}${it.count > 1 ? ` x${it.count}` : ''}`);
|
|
148
218
|
if (q.rewChoiceItems?.length) out.push('Choose one of: ' + q.rewChoiceItems.map((it) => app.itemLabel(it.item)).join(', '));
|
|
@@ -154,17 +224,17 @@ export function rewardLines(app, q) {
|
|
|
154
224
|
|
|
155
225
|
export function makeTabs(app) {
|
|
156
226
|
return [
|
|
157
|
-
{ label: 'World', view: worldView(app) },
|
|
158
|
-
{ label: 'Quests', view: questsView(app) },
|
|
159
|
-
{ label: 'Bags', view: bagsView(app) },
|
|
160
|
-
{ label: 'Gear', view: gearView(app) },
|
|
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: 'Character', short: 'Char', view: charView(app) },
|
|
227
|
+
{ label: 'World', icon: 'inv_misc_map_01', view: worldView(app) },
|
|
228
|
+
{ label: 'Quests', icon: 'inv_misc_note_01', view: questsView(app) },
|
|
229
|
+
{ label: 'Bags', icon: 'inv_misc_bag_08', view: bagsView(app) },
|
|
230
|
+
{ label: 'Gear', icon: 'inv_chest_chain_05', view: gearView(app) },
|
|
231
|
+
{ label: 'Spells', icon: 'inv_misc_book_09', view: spellsView(app) },
|
|
232
|
+
{ label: 'Rotation', icon: 'ability_dualwield', view: rotationView(app) },
|
|
233
|
+
{ label: 'Talents', icon: 'spell_nature_naturetouchgrow', view: talentsView(app) },
|
|
234
|
+
{ label: 'Travel', icon: 'spell_nature_swiftness', view: travelView(app) },
|
|
235
|
+
{ label: 'Professions', short: 'Profs', icon: 'trade_blacksmithing', view: professionsView(app) },
|
|
236
|
+
{ label: 'Companions', short: 'Party', icon: 'spell_holy_prayerofhealing', view: companionsView(app) },
|
|
237
|
+
{ label: 'Character', short: 'Char', icon: 'inv_misc_head_human_01', view: charView(app) },
|
|
168
238
|
];
|
|
169
239
|
}
|
|
170
240
|
|
|
@@ -173,28 +243,30 @@ function worldView(app) {
|
|
|
173
243
|
id: 'world', title: 'World', hint: 'Enter: open / fight',
|
|
174
244
|
keys: {
|
|
175
245
|
s: { hint: 'search', fn: (a) => openWorldSearch(a) },
|
|
246
|
+
S: { fn: (a) => openWorldSearch(a, '', true) },
|
|
176
247
|
f: { hint: 'quick fight', fn: (a, row) => { if (row?.data?.creature) a.cmds.commands.fight(String(row.data.creature.entry)); } },
|
|
177
248
|
t: { fn: (a, row) => { const c = row?.data?.creature; if (!c) return; if (a.ch.cls !== 3) { a.flashMsg('Only hunters tame beasts.'); return; } a.result(a.g.tamePet(c.entry), `You tame ${c.name}.`); } },
|
|
178
249
|
P: { fn: (a, row) => { if (row?.data?.creature) pickPocket(a, row.data.creature); } },
|
|
179
|
-
a: { hint: 'autopilot', fn: (a) =>
|
|
250
|
+
a: { hint: 'autopilot', fn: (a) => a.push(preflightCard(a)) },
|
|
180
251
|
},
|
|
181
252
|
rows(a) {
|
|
182
253
|
const ds = a.ds, g = a.g, z = a.zone(), ch = a.ch;
|
|
183
254
|
if (!z) return [{ text: 'You are nowhere.', header: true }];
|
|
184
255
|
const rows = [...journeyRows(a), ...meetingStoneQueueRows(a), { text: `${z.name} ${dim(`levels ${z.levelRange[0]}-${z.levelRange[1]}${z.faction ? `, ${z.faction}` : ''}`)}`, header: true }];
|
|
185
256
|
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
|
-
|
|
188
|
-
for (const
|
|
257
|
+
for (const t of z.trainers ?? []) if (t.cls === ch.cls) { const d = ds.trainer(t.entry); if (d) people.push({ text: `${d.name} ${dim(`<${className(t.cls)} Trainer>`)}`, detail: () => npcLines(a, d.entry, [bold(d.name), `${className(t.cls)} trainer`, '', dim('Enter: see what you can learn')]), act: () => a.push(trainerDialog(a, d)) }); }
|
|
258
|
+
const innEntries = new Set((z.innkeepers ?? []).map((i) => i.entry)); // innkeepers sell too, but list once, as the inn
|
|
259
|
+
for (const v of z.vendors ?? []) { const d = !innEntries.has(v) && ds.vendor(v); if (d) people.push({ text: `${d.name} ${dim(`<${d.subname ?? 'Vendor'}>`)}${d.canRepair ? dim(' repairs') : ''}`, detail: () => npcLines(a, d.entry, [bold(d.name), d.subname ?? 'Vendor', ...(d.canRepair ? ['Repairs armor and weapons'] : []), '', dim('Enter: browse wares')]), act: () => a.push(vendorDialog(a, d)) }); }
|
|
260
|
+
for (const i of z.innkeepers ?? []) people.push({ text: `${i.name} ${dim('<Innkeeper>')}`, detail: () => npcLines(a, i.entry, [bold(i.name), 'Innkeeper', '', dim('Enter: browse wares, make this your home')]), act: () => a.push(innDialog(a, i)) });
|
|
189
261
|
for (const p of z.profTrainers ?? []) {
|
|
190
262
|
const d = ds.profTrainer(p);
|
|
191
263
|
if (!d) continue;
|
|
192
264
|
const prof = ds.profession(d.skill)?.name ?? d.skill;
|
|
193
265
|
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)) });
|
|
266
|
+
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
267
|
}
|
|
196
268
|
people.push(...ridingRows(a));
|
|
197
|
-
for (const f of z.flightMasters ?? []) people.push({ text: `${f.name} ${dim('<Flight Master>')}`, detail: () => [bold(f.name), 'Flight master', '', dim('Enter: open the travel tab')], act: () => a.switchTab(7) });
|
|
269
|
+
for (const f of z.flightMasters ?? []) 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
270
|
// City services: the bank in capitals and banker towns, the auction house in capitals, the mailbox by every inn.
|
|
199
271
|
const city = cityAccess(a);
|
|
200
272
|
const services = [];
|
|
@@ -223,19 +295,13 @@ function worldView(app) {
|
|
|
223
295
|
rows.push({ text: `${conPaint(ch.level, m.level)(m.name)} ${dim(`L${m.level}`)}${wanted ? yellow(' !') : ''}${ch.cls === 4 ? pickPocketTag(a, m) : ''}`, data: { creature: m }, detail: () => creatureLines(a, m), act: () => startFight(a, m.entry) });
|
|
224
296
|
}
|
|
225
297
|
}
|
|
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));
|
|
298
|
+
// Last: hand-ins and the zone's offers (the Quests tab shows the same list under its log).
|
|
231
299
|
const ready = g.state.questlog.quests.filter((qs) => qs.status === 'complete').map((qs) => ({ qs, q: ds.quest(qs.entry) })).filter((x) => x.q);
|
|
300
|
+
const offered = zoneOfferRows(a);
|
|
232
301
|
if (offered.length || ready.length) {
|
|
233
302
|
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
|
-
}
|
|
303
|
+
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')) });
|
|
304
|
+
rows.push(...offered);
|
|
239
305
|
}
|
|
240
306
|
return rows;
|
|
241
307
|
},
|
|
@@ -258,14 +324,35 @@ export function travelTo(app, dest) {
|
|
|
258
324
|
if (!app.result(res)) return;
|
|
259
325
|
app.vendor = null; app.cmds.setVendor(null);
|
|
260
326
|
afterDeparture(app, () => `You travel to ${bold(dest.name)}. ${dim(`${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${moneyText(pv.totalCopper)}` : ''}`)}`);
|
|
327
|
+
landOnTab(app);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** After a departure: stay on the Travel tab when the trip started there (you may want to go on), else open World. */
|
|
331
|
+
export function landOnTab(app) {
|
|
332
|
+
const travel = app.tabs.findIndex((t) => t.view?.id === 'travel');
|
|
333
|
+
const i = app.tab === travel && !app.stack.length ? travel : 0;
|
|
261
334
|
app.stack = [];
|
|
262
|
-
app.switchTab(
|
|
263
|
-
app.tabs[
|
|
335
|
+
app.switchTab(i);
|
|
336
|
+
const v = app.tabs[i]?.view; if (v) { v.cursor = 0; v.scroll = 0; }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The quests this zone offers that are not in the log: the same offer list the autopilot reads (chain, class,
|
|
340
|
+
* race and exclusive-group gates applied). `canAccept` false means visible but level- or reputation-locked
|
|
341
|
+
* (shown grey, like a silver !); quests more than five levels above you are left out. */
|
|
342
|
+
export function zoneOfferRows(a) {
|
|
343
|
+
const z = a.zone(), ch = a.ch;
|
|
344
|
+
if (!z) return [];
|
|
345
|
+
const have = new Set(a.g.state.questlog.quests.map((qs) => qs.entry));
|
|
346
|
+
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));
|
|
347
|
+
return offered.map((o) => {
|
|
348
|
+
const q = o.quest;
|
|
349
|
+
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')) };
|
|
350
|
+
});
|
|
264
351
|
}
|
|
265
352
|
|
|
266
353
|
function questsView(app) {
|
|
267
354
|
return {
|
|
268
|
-
id: 'quests', title: 'Quest log', hint: 'Enter: open', empty: 'Your quest log is empty. Look for ! in the world.',
|
|
355
|
+
id: 'quests', title: 'Quest log', hint: 'Enter: open', empty: 'Your quest log is empty and this zone offers nothing. Look for ! in the world.',
|
|
269
356
|
keys: {
|
|
270
357
|
d: { hint: 'do quest', fn: (a, row) => { if (row?.data) doQuest(a, row.data.q); } },
|
|
271
358
|
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}.`)); } },
|
|
@@ -278,8 +365,14 @@ function questsView(app) {
|
|
|
278
365
|
rows.push({ text: `${log.length} of 20 quests`, header: true });
|
|
279
366
|
for (const x of log) {
|
|
280
367
|
const st = x.qs.status === 'complete' ? green('(Complete)') : x.qs.status === 'failed' ? red('(Failed)') : a.cmds.questBlacklist.has(x.q.entry) ? dim('(autopilot skips)') : '';
|
|
281
|
-
rows.push({ text: `${x.q.title}
|
|
368
|
+
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')) });
|
|
282
369
|
}
|
|
370
|
+
// Below the log: what this zone offers that you have not taken, so you need not scroll the World tab for it.
|
|
371
|
+
const offered = zoneOfferRows(a);
|
|
372
|
+
if (offered.length) {
|
|
373
|
+
if (log.length) rows.push({ text: '', header: true });
|
|
374
|
+
rows.push({ text: `Available in ${a.zone()?.name ?? 'this zone'} (${offered.length})`, header: true }, ...offered);
|
|
375
|
+
} else if (!log.length) return [];
|
|
283
376
|
return rows;
|
|
284
377
|
},
|
|
285
378
|
};
|
|
@@ -292,14 +385,19 @@ export function questDialog(app, q, mode) {
|
|
|
292
385
|
rows(a) {
|
|
293
386
|
const W = a.layout().listW - 4;
|
|
294
387
|
const ch = a.ch, s = qs();
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
rows.push({ text:
|
|
301
|
-
|
|
302
|
-
|
|
388
|
+
// Each part reads apart: the title in its level colour, cyan section headings, the story in plain
|
|
389
|
+
// text, the objective in bold with its counts, rewards in their own paint.
|
|
390
|
+
const P = (t) => ({ text: t, header: true, plain: true });
|
|
391
|
+
const rows = [P(`${conPaint(ch.level, q.questLevel)(bold(q.title))} ${dim(`L${q.questLevel}`)}`)];
|
|
392
|
+
const text = (t, paint = (l) => l) => wrapLines(questText(t, ch), W).map((l) => P(paint(l)));
|
|
393
|
+
const section = (title, lines) => { if (lines.length) rows.push(P(''), { text: title, header: true }, ...lines); };
|
|
394
|
+
const story = () => section(s?.status === 'complete' ? 'Completion' : 'Description',
|
|
395
|
+
text(s?.status === 'complete' ? q.offerRewardText || q.endText || '' : q.detailsText || q.requestItemsText || ''));
|
|
396
|
+
const objectives = () => section('Objectives', [...text(q.objectivesText ?? '', bold), ...objectiveLines(a, q, s).map((o) => P(` ${o}`))]);
|
|
397
|
+
// Offered, the story leads as the giver tells it; in the log, what is left to do comes first.
|
|
398
|
+
if (mode === 'offer' || s?.status === 'complete') { story(); objectives(); } else { objectives(); story(); }
|
|
399
|
+
section('Rewards', rewardLines(a, q).map((r) => P(` ${r}`)));
|
|
400
|
+
rows.push(P(''));
|
|
303
401
|
if (mode === 'offer') {
|
|
304
402
|
rows.push({ text: green('Accept the quest'), act: () => { if (a.result(a.g.acceptQuest(q.entry), `Quest accepted: ${q.title}`)) a.pop(); } });
|
|
305
403
|
rows.push({ text: dim('Decline'), act: () => a.pop() });
|
|
@@ -308,7 +406,7 @@ export function questDialog(app, q, mode) {
|
|
|
308
406
|
// A repeatable with a fixed reward hands in every round the bags pay for in one go.
|
|
309
407
|
const rounds = s?.status === 'complete' && isRepeatableQuest(q) && questRewardChoices(q).length <= 1 ? questRepeatsAvailable(q, a.g.state.inventory, a.ch.zoneId) : 0;
|
|
310
408
|
if (rounds >= 2) rows.push({ text: green(`Hand it in ${rounds} times`), act: () => repeatTurnIn(a, q) });
|
|
311
|
-
else rows.push({ text:
|
|
409
|
+
else rows.push({ text: 'Do the quest', act: () => doQuest(a, q) });
|
|
312
410
|
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__'); }) });
|
|
313
411
|
rows.push({ text: dim('Close'), act: () => a.pop() });
|
|
314
412
|
}
|
|
@@ -324,7 +422,7 @@ function doQuest(app, q) {
|
|
|
324
422
|
const s = app.g.state.questlog.quests.find((x) => x.entry === q.entry);
|
|
325
423
|
if (s?.status === 'complete' && q.rewChoiceItems?.length) { turnIn(app, q); return; }
|
|
326
424
|
app.stack = [];
|
|
327
|
-
startAutopilot(app,
|
|
425
|
+
startAutopilot(app, Infinity, undefined, q.entry);
|
|
328
426
|
}
|
|
329
427
|
|
|
330
428
|
function repeatTurnIn(app, q) {
|
|
@@ -379,7 +477,7 @@ function bagsView(app) {
|
|
|
379
477
|
const slotOf = (a, row) => (row?.data && !('keyring' in row.data) ? a.g.state.inventory.stacks.indexOf(row.data) : -1);
|
|
380
478
|
const onStack = (fn) => (a, row) => { const i = slotOf(a, row); if (i >= 0) fn(a, i); };
|
|
381
479
|
return {
|
|
382
|
-
id: 'bags', title: 'Bags', hint: 'Enter: use / equip / open
|
|
480
|
+
id: 'bags', title: 'Bags', hint: 'Enter: use / equip / open', empty: 'Your bags are empty.',
|
|
383
481
|
keys: {
|
|
384
482
|
m: { hint: 'item menu', fn: onStack(itemMenu) },
|
|
385
483
|
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); } },
|
|
@@ -407,6 +505,7 @@ function bagsView(app) {
|
|
|
407
505
|
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') : '';
|
|
408
506
|
rows.push({
|
|
409
507
|
text: `${name}${s.count > 1 ? ` x${s.count}` : ''}${tag}${ups.has(s.item) ? green(' ▲') : ''}`, data: s,
|
|
508
|
+
icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${moneyText(it.sellPrice * s.count)}` : ''}`),
|
|
410
509
|
detail: () => itemLines(a, it, [
|
|
411
510
|
'', dim(`Enter: ${verb ? verb.toLowerCase() : 'more'} m: more${s.count > 1 ? ' /: split' : ''} [ ]: move d: destroy`),
|
|
412
511
|
...(a.vendor ? [dim(`s: sell to ${a.vendor.name}`)] : []),
|
|
@@ -426,9 +525,19 @@ function wornItem(app, slot) {
|
|
|
426
525
|
return entry == null ? null : app.ds.item(entry) ?? null;
|
|
427
526
|
}
|
|
428
527
|
|
|
429
|
-
/** The engine's equipment helper: what in your bags
|
|
528
|
+
/** The engine's equipment helper: what in your bags, or unbound in a companion's (a crafter's product), beats what you wear. */
|
|
430
529
|
function upgradeList(app) {
|
|
431
|
-
return app.g.suggestedEquipmentUpgrades()
|
|
530
|
+
return app.g.suggestedEquipmentUpgrades();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Companions you own, squad first, each once. */
|
|
534
|
+
const ownedCompanions = (g) => [...new Map([...(g.state.squad ?? []), ...Object.values(g.state.botRoster ?? {})].map((c) => [c.id, c])).values()];
|
|
535
|
+
|
|
536
|
+
/** Wear `entry` in `slot`, first taking one unbound copy from companion `holderId` when it is theirs. */
|
|
537
|
+
function equipFrom(a, entry, slot, holderId) {
|
|
538
|
+
if (holderId && !a.result(a.g.takeItemFromBot(holderId, entry, 1), '')) return false;
|
|
539
|
+
const r = a.g.equip(entry, slot);
|
|
540
|
+
return a.result(r, r.ok ? `You equip ${a.ds.item(entry)?.name ?? entry}.` : '');
|
|
432
541
|
}
|
|
433
542
|
|
|
434
543
|
/** Lines comparing a candidate with the item worn in `slot`: armor, damage and stat deltas. */
|
|
@@ -452,42 +561,63 @@ function gearView(app) {
|
|
|
452
561
|
id: 'gear', title: 'Equipment', hint: 'Enter: change what you wear',
|
|
453
562
|
keys: {
|
|
454
563
|
u: { hint: 'equip all upgrades', fn: (a) => {
|
|
455
|
-
if (!upgradeList(a).length) { a.flashMsg(
|
|
456
|
-
|
|
564
|
+
if (!upgradeList(a).length) { a.flashMsg("Nothing in your bags or your companions' beats what you wear."); return; }
|
|
565
|
+
// Best first, re-scored after each piece (two rings, a two-hander). Works in suggest mode too,
|
|
566
|
+
// where autoEquipUpgrades stands down. Each entry is tried once, so displaced pieces cannot ping-pong.
|
|
567
|
+
let n = 0;
|
|
568
|
+
const tried = new Set();
|
|
569
|
+
for (let i = 0; i < 16; i++) {
|
|
570
|
+
const u = upgradeList(a).find((x) => !tried.has(x.entry));
|
|
571
|
+
if (!u) break;
|
|
572
|
+
tried.add(u.entry);
|
|
573
|
+
if (u.holderId && !a.g.takeItemFromBot(u.holderId, u.entry, 1).ok) continue;
|
|
574
|
+
if (a.g.equip(u.entry, u.slot).ok) n++;
|
|
575
|
+
}
|
|
457
576
|
a.say(n ? green(`You put on ${plural(n, 'upgrade')}.`) : red('The upgrades could not be equipped.'));
|
|
458
577
|
} },
|
|
459
578
|
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}.`); } },
|
|
460
579
|
r: { hint: 'repair', fn: (a, row) => { if (row?.data?.it) repairOne(a, row.data.slot); } },
|
|
580
|
+
R: { hint: 'repair all', fn: (a) => repairAll(a) },
|
|
461
581
|
o: { hint: 'outfits', fn: (a) => a.push(outfitterDialog(a)) },
|
|
462
582
|
},
|
|
583
|
+
// Drawn as the character sheet (tuiPaperdoll.mjs) when colour is on and the window has room.
|
|
584
|
+
grid: (a, rows, w, h) => paperdollGrid(a, rows, w, h, (cols, lines) => playerArt(a, cols, lines)?.lines ?? null),
|
|
463
585
|
rows(a) {
|
|
464
586
|
const rows = [];
|
|
465
587
|
const ups = upgradeList(a);
|
|
466
|
-
|
|
467
|
-
|
|
588
|
+
const extras = gearExtraRows(a);
|
|
589
|
+
const sheet = paperdollPlan(a, extras.filter((r) => !r.header).length);
|
|
590
|
+
if (ups.length && !sheet) {
|
|
591
|
+
rows.push({ text: `${green('▲')} Better gear for you (${ups.length}) ${dim('Enter: put on u: put on all')}`, header: true });
|
|
468
592
|
for (const u of ups) {
|
|
469
593
|
const it = a.ds.item(u.entry), cur = u.currentEntry != null ? a.ds.item(u.currentEntry) : null;
|
|
470
594
|
rows.push({
|
|
471
|
-
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(', ')}`) : ''}`,
|
|
595
|
+
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`) : ''}`,
|
|
472
596
|
data: { upgrade: u },
|
|
473
|
-
detail: () => itemLines(a, it, compareLines(a, it, cur)),
|
|
474
|
-
act: () =>
|
|
597
|
+
detail: () => itemLines(a, it, [...compareLines(a, it, cur), ...(u.holderId ? ['', dim(`In ${u.holderName}'s bags. Enter takes it and puts it on.`)] : [])]),
|
|
598
|
+
act: () => equipFrom(a, u.entry, u.slot, u.holderId),
|
|
475
599
|
});
|
|
476
600
|
}
|
|
477
601
|
rows.push({ text: '', header: true });
|
|
478
602
|
}
|
|
479
|
-
rows.push({ text: `What you wear ${dim('Enter: pick from bags' + (cityAccess(a).bank ? '
|
|
603
|
+
if (!sheet) rows.push({ text: `What you wear ${dim('Enter: pick from bags' + (cityAccess(a).bank ? ', bank' : '') + ' and companions x: take off')}`, header: true });
|
|
480
604
|
for (const slot of [...EQUIP_SLOTS, ...BAG_SLOTS]) {
|
|
481
605
|
const it = wornItem(a, slot);
|
|
482
606
|
const up = ups.some((u) => u.slot === slot);
|
|
483
607
|
const dur = it ? durabilityText(a, slot) : '';
|
|
608
|
+
const kind = it ? [it.typeName, it.armor ? `${it.armor} armor` : '', it.weapon ? `${it.weapon.dmgMin}-${it.weapon.dmgMax}` : ''].filter(Boolean).join(' ') : '';
|
|
609
|
+
// The best piece waiting for this slot, said in the tooltip (the sheet has no upgrade rows).
|
|
610
|
+
const best = ups.filter((u) => u.slot === slot).sort((x, y) => y.delta - x.delta)[0];
|
|
611
|
+
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')] : [];
|
|
484
612
|
rows.push({
|
|
485
613
|
text: `${fit(SLOT_LABEL[slot], 10)} ${it ? qualityPaint(it.quality)(it.name) : dim('—')}${up ? green(' ▲') : ''}${dur ? ` ${dur}` : ''}`, data: { slot, it },
|
|
486
|
-
|
|
614
|
+
icon: it?.icon, sub: it ? dim(`${fit('', 10)} ${kind}`) : undefined,
|
|
615
|
+
cell: { slot, it, up, dur, sub: dim(kind) },
|
|
616
|
+
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]),
|
|
487
617
|
act: () => a.push(slotDialog(a, slot)),
|
|
488
618
|
});
|
|
489
619
|
}
|
|
490
|
-
rows.push(...
|
|
620
|
+
rows.push(...(sheet ? extras.filter((r) => !r.header).slice(0, sheet.extras) : extras));
|
|
491
621
|
return rows;
|
|
492
622
|
},
|
|
493
623
|
};
|
|
@@ -506,19 +636,18 @@ function slotDialog(app, slot) {
|
|
|
506
636
|
const ups = new Map(upgradeList(a).filter((u) => u.slot === slot).map((u) => [u.entry, u]));
|
|
507
637
|
const rows = [{ text: `${bold(label)}: ${worn ? qualityPaint(worn.quality)(worn.name) : dim('empty')}`, header: true }, { text: '', header: true }];
|
|
508
638
|
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(); } });
|
|
509
|
-
const row = (s, from) => {
|
|
639
|
+
const row = (s, from, comp) => {
|
|
510
640
|
const it = ds.item(s.item);
|
|
511
641
|
const up = ups.get(s.item);
|
|
512
642
|
const low = it.requiredLevel > ch.level;
|
|
513
643
|
const name = qualityPaint(it.quality)(it.name);
|
|
514
644
|
return {
|
|
515
|
-
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') : ''}`,
|
|
645
|
+
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}`) : ''}`,
|
|
516
646
|
data: { s, it },
|
|
517
|
-
detail: () => itemLines(a, it, [...compareLines(a, it, worn), '', dim(from === 'bank' ? 'Enter: take it out of the bank and wear it' : 'Enter: wear it')]),
|
|
647
|
+
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')]),
|
|
518
648
|
act: () => {
|
|
519
649
|
if (from === 'bank' && !a.result(a.g.bankWithdraw(s.item, 1), '')) return;
|
|
520
|
-
|
|
521
|
-
if (a.result(r, r.ok ? `You equip ${it.name}.` : '')) a.pop();
|
|
650
|
+
if (equipFrom(a, s.item, slot, comp?.id)) a.pop();
|
|
522
651
|
},
|
|
523
652
|
};
|
|
524
653
|
};
|
|
@@ -532,6 +661,12 @@ function slotDialog(app, slot) {
|
|
|
532
661
|
if (!bank.length) rows.push({ text: dim(' Nothing in the bank fits either.'), header: true });
|
|
533
662
|
for (const s of bank) rows.push(row(s, 'bank'));
|
|
534
663
|
}
|
|
664
|
+
// Unbound pieces a companion carries (a crafter's product, a BoE they looted) can come straight over.
|
|
665
|
+
const held = ownedCompanions(a.g).flatMap((c) => (c.bags?.stacks ?? []).filter((s) => fits(s) && !stackBoundToHolder(s, ds)).map((s) => ({ s, c })));
|
|
666
|
+
if (held.length) {
|
|
667
|
+
rows.push({ text: '', header: true }, { text: `In your companions' bags (${held.length})`, header: true });
|
|
668
|
+
for (const { s, c } of held) rows.push(row(s, 'companion', c));
|
|
669
|
+
}
|
|
535
670
|
return rows;
|
|
536
671
|
},
|
|
537
672
|
};
|
|
@@ -550,6 +685,7 @@ function spellsView(app) {
|
|
|
550
685
|
const cd = a.g.spellCooldownRemaining(sp.id);
|
|
551
686
|
return {
|
|
552
687
|
text: `${sp.name}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''}${sp.cost ? dim(` ${sp.cost} ${sp.powerType}`) : ''}${cd > 0 ? red(` ${secondsText(cd)}`) : ''}`,
|
|
688
|
+
icon: sp.icon, sub: dim(`${sp.castTimeMs ? `${(sp.castTimeMs / 1000).toFixed(1)}s cast` : sp.channelMs ? 'channeled' : 'instant'}${sp.cooldownMs ? ` ${secondsText(sp.cooldownMs)} cooldown` : ''}`),
|
|
553
689
|
detail: () => spellLines(a, sp),
|
|
554
690
|
act: () => castWithPicker(a, sp.id),
|
|
555
691
|
};
|
|
@@ -568,6 +704,11 @@ function rotNames(app) {
|
|
|
568
704
|
item: (id) => app.itemName(id),
|
|
569
705
|
creature: (e) => app.ds.creature(e)?.name,
|
|
570
706
|
object: (e) => app.ds.gameObject?.(e)?.name,
|
|
707
|
+
seat: (unit) => {
|
|
708
|
+
if (unit === 'player') return app.g.state.char.name || 'You';
|
|
709
|
+
const i = allyIndexOfSide(unit);
|
|
710
|
+
return (i >= 0 ? (app.g.state.squad ?? [])[i]?.name : undefined) ?? unit;
|
|
711
|
+
},
|
|
571
712
|
};
|
|
572
713
|
}
|
|
573
714
|
|
|
@@ -580,6 +721,7 @@ function rotOwner(app) {
|
|
|
580
721
|
return {
|
|
581
722
|
name: 'your', comp: null, get: () => g.rotation(), set: (r) => g.setRotation(r), reset: () => g.resetRotation(),
|
|
582
723
|
spells: () => knownUsableSpells(app.ch, g.state.spellbook, app.ds), bag: () => g.state.inventory.stacks,
|
|
724
|
+
char: () => app.ch, spellbook: () => g.state.spellbook, equipped: () => app.ch.equipped,
|
|
583
725
|
profiles: () => g.rotationProfiles(), add: (o) => g.addRotationProfile(o), rename: (pid, n) => g.renameRotationProfile(pid, n),
|
|
584
726
|
del: (pid) => g.deleteRotationProfile(pid), select: (pid) => g.selectRotationProfile(pid),
|
|
585
727
|
};
|
|
@@ -588,6 +730,7 @@ function rotOwner(app) {
|
|
|
588
730
|
return {
|
|
589
731
|
name: `${comp.name}'s`, comp, get: () => g.companionRotation(comp.id), set: (r) => g.setCompanionRotation(comp.id, r), reset: () => g.resetCompanionRotation(comp.id),
|
|
590
732
|
spells: () => knownUsableSpells(companionCharacter(comp, lvl, app.ds), companionSpellbook(comp, lvl, app.ds), app.ds), bag: () => comp.bags?.stacks ?? [],
|
|
733
|
+
char: () => companionCharacter(comp, lvl, app.ds), spellbook: () => companionSpellbook(comp, lvl, app.ds), equipped: () => comp.equipped,
|
|
591
734
|
profiles: () => g.companionRotationProfiles(comp.id), add: (o) => g.addCompanionRotationProfile(comp.id, o), rename: (pid, n) => g.renameCompanionRotationProfile(comp.id, pid, n),
|
|
592
735
|
del: (pid) => g.deleteCompanionRotationProfile(comp.id, pid), select: (pid) => g.selectCompanionRotationProfile(comp.id, pid),
|
|
593
736
|
};
|
|
@@ -670,9 +813,13 @@ function rotationView(app) {
|
|
|
670
813
|
rows.push({ text: '', header: true }, { text: PHASE_LABEL[phase], header: true });
|
|
671
814
|
rules.forEach((rule, index) => {
|
|
672
815
|
const conds = conditionsSentence(rule.conditions, names);
|
|
673
|
-
|
|
816
|
+
// With icons a rule is two lines and the condition takes the second; without, it trails the sentence.
|
|
817
|
+
const icon = iconsOn() ? a.ds.spell(rule.action?.spellId ?? rule.before?.spellId)?.icon : undefined;
|
|
818
|
+
const when = conds !== 'always' ? dim(`when ${conds}`) : '';
|
|
819
|
+
const text = `${rule.enabled ? green('✓') : dim('·')} ${rule.enabled ? ruleSentence(rule, names) : dim(ruleSentence(rule, names))}${!icon && when ? ` ${when}` : ''}`;
|
|
674
820
|
rows.push({
|
|
675
821
|
text, data: { phase, index, rule },
|
|
822
|
+
icon, sub: when,
|
|
676
823
|
detail: () => ruleLines(a, rule, names),
|
|
677
824
|
act: () => ruleMenu(a, { phase, index, rule }),
|
|
678
825
|
});
|
|
@@ -705,7 +852,7 @@ function ruleMenu(app, data) {
|
|
|
705
852
|
const names = rotNames(app);
|
|
706
853
|
const options = [
|
|
707
854
|
{ 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' },
|
|
708
|
-
{ label: 'Change when it fires', detail: ['
|
|
855
|
+
{ 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' },
|
|
709
856
|
{ label: 'Move up', detail: ['Earlier rules are tried first.'], value: 'up' },
|
|
710
857
|
{ label: 'Move down', detail: ['Later rules only fire when everything above them is unusable or gated off.'], value: 'down' },
|
|
711
858
|
{ label: 'Add a rule after this one', value: 'add' },
|
|
@@ -759,10 +906,20 @@ function addRule(app, phase, afterIndex) {
|
|
|
759
906
|
const options = known.map((sp) => ({ label: `${sp.name}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''}`, detail: () => spellLines(app, sp), value: { kind: 'spell', spellId: sp.id } }));
|
|
760
907
|
const combatItems = [...new Set(o.bag().map((s) => s.item))].map((e) => app.ds.item(e)).filter((it) => it?.useInCombat);
|
|
761
908
|
for (const it of combatItems) options.push({ label: `Use ${qualityPaint(it.quality)(it.name)}`, detail: () => itemLines(app, it), value: { kind: 'item', itemEntry: it.entry } });
|
|
762
|
-
app.pick(`Add to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}: what to do`, options, (
|
|
909
|
+
app.pick(`Add to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}: what to do`, options, (picked) => {
|
|
763
910
|
const id = `tui-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
|
|
911
|
+
// A new rule starts with the engine's smart default for what it does: a seal or buff only when it is
|
|
912
|
+
// missing, a DoT when the enemy lacks it, a form when not in it, an interrupt when the enemy casts, a
|
|
913
|
+
// potion below a health band. The same gate the recommended list would give it; c changes it.
|
|
914
|
+
let action = picked, conditions = [];
|
|
915
|
+
if (picked.kind === 'spell') {
|
|
916
|
+
const sp = app.ds.spell(picked.spellId);
|
|
917
|
+
if (sp) conditions = defaultConditionsForSpell(sp, o.char());
|
|
918
|
+
} else if (picked.kind === 'item') {
|
|
919
|
+
({ action, conditions } = defaultItemRule(app, o, picked.itemEntry));
|
|
920
|
+
}
|
|
764
921
|
editRotation(app, (rot) => {
|
|
765
|
-
const rule = { id, action, conditions
|
|
922
|
+
const rule = { id, action, conditions, enabled: true };
|
|
766
923
|
const list = rot.phases[phase] ??= [];
|
|
767
924
|
list.splice(afterIndex == null ? list.length : afterIndex + 1, 0, rule);
|
|
768
925
|
});
|
|
@@ -770,39 +927,14 @@ function addRule(app, phase, afterIndex) {
|
|
|
770
927
|
const view = app.tabs[5].view;
|
|
771
928
|
const at = app.rowsOf(view).findIndex((r) => r.data?.rule?.id === id);
|
|
772
929
|
if (at >= 0) { view.cursor = at; app.ensureVisible(view, app.rowsOf(view)); }
|
|
773
|
-
|
|
930
|
+
const when = conditions.length ? `only when ${conditionsSentence(conditions, rotNames(app))}` : 'with no conditions';
|
|
931
|
+
app.say(green(`Rule added to ${phase}, ${when}. Enter on it to change when it fires.`));
|
|
774
932
|
});
|
|
775
933
|
}
|
|
776
934
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
const presets = [
|
|
781
|
-
{ label: 'Always', value: [] },
|
|
782
|
-
{ label: 'My health is below …%', value: 'hp', prompt: { metric: 'healthPct', subject: 'self', op: '<' } },
|
|
783
|
-
{ label: 'My mana/power is above …%', value: 'pw', prompt: { metric: 'powerPct', subject: 'self', op: '>' } },
|
|
784
|
-
{ label: "The enemy's health is below …%", value: 'ehp', prompt: { metric: 'healthPct', subject: 'target', op: '<' } },
|
|
785
|
-
{ label: "The enemy's health is above …%", value: 'ehp2', prompt: { metric: 'healthPct', subject: 'target', op: '>' } },
|
|
786
|
-
...(spellRef ? [
|
|
787
|
-
{ label: `The enemy does not have ${spellName}`, value: [{ subject: 'target', metric: 'debuffPresent', op: '==', value: 0, ref: spellRef }] },
|
|
788
|
-
{ label: `I do not have ${spellName}`, value: [{ subject: 'self', metric: 'buffPresent', op: '==', value: 0, ref: spellRef }] },
|
|
789
|
-
{ label: `${spellName} has under 3 s left on the enemy`, value: [{ subject: 'target', metric: 'debuffRemainingMs', op: '<', value: 3000, ref: spellRef }] },
|
|
790
|
-
] : []),
|
|
791
|
-
{ label: 'The enemy is casting', value: [{ subject: 'target', metric: 'castingPresent', op: '==', value: 1 }] },
|
|
792
|
-
{ label: 'More than one living enemy', value: [{ subject: 'self', metric: 'enemyCount', op: '>', value: 1 }] },
|
|
793
|
-
{ label: 'I am in melee range', value: [{ subject: 'self', metric: 'inMelee', op: '==', value: 1 }] },
|
|
794
|
-
{ label: 'I am at range', value: [{ subject: 'self', metric: 'inMelee', op: '==', value: 0 }] },
|
|
795
|
-
];
|
|
796
|
-
const apply = (conds) => editRotation(app, (rot) => { rot.phases[phase][index].conditions = conds; });
|
|
797
|
-
app.pick(`${spellName}: fire only when…`, presets.map((p) => ({ label: p.label, value: p })), (p) => {
|
|
798
|
-
if (Array.isArray(p.value)) { apply(p.value); return; }
|
|
799
|
-
app.prompt('Percent: ', (txt) => {
|
|
800
|
-
const n = Number(txt);
|
|
801
|
-
if (!(n >= 0 && n <= 100)) { app.flashMsg('Enter a number from 0 to 100.'); return; }
|
|
802
|
-
apply([{ ...p.prompt, value: n }]);
|
|
803
|
-
app.afterAction();
|
|
804
|
-
});
|
|
805
|
-
});
|
|
935
|
+
/** Enter on a rule, "Change when it fires": the full condition editor (tuiRotationCond.mjs). */
|
|
936
|
+
function editCondition(app, { phase, index }) {
|
|
937
|
+
conditionEditor(app, { phase, index, owner: rotOwner(app), names: rotNames(app), mutate: (fn) => editRotation(app, fn) });
|
|
806
938
|
}
|
|
807
939
|
|
|
808
940
|
function pickSpec(app) {
|
|
@@ -816,6 +948,9 @@ function pickSpec(app) {
|
|
|
816
948
|
function talentsView(app) {
|
|
817
949
|
return {
|
|
818
950
|
id: 'talents', title: 'Talents', hint: 'Enter: spend a point',
|
|
951
|
+
// Drawn as the three trees (tuiTalentTree.mjs) when colour is on and they fit; a narrower tooltip makes room.
|
|
952
|
+
detailShare: 0.34,
|
|
953
|
+
grid: (a, rows, w, h) => talentGrid(a, rows, w, h),
|
|
819
954
|
keys: {
|
|
820
955
|
u: { hint: 'refund', fn: (a, row) => row?.data && a.result(a.g.refundTalent(row.data.id), `Refunded a point from ${row.data.name}.`) },
|
|
821
956
|
R: { hint: 'respec', fn: (a) => { const cost = a.g.respecCost(); a.confirm(`Reset all talents for ${moneyText(cost) || 'free'}?`, () => a.result(a.g.respec(), 'Your talents are reset.')); } },
|
|
@@ -824,6 +959,16 @@ function talentsView(app) {
|
|
|
824
959
|
rows(a) {
|
|
825
960
|
const ch = a.ch;
|
|
826
961
|
const free = availablePoints(ch);
|
|
962
|
+
if (talentPlan(a)) {
|
|
963
|
+
return a.ds.talentTrees(ch.cls).flatMap((tree, k) => [...tree.talents].sort((x, y) => x.tier - y.tier || x.col - y.col).map((t) => {
|
|
964
|
+
const rank = ch.talents?.[t.id] ?? 0;
|
|
965
|
+
return {
|
|
966
|
+
text: t.name, data: t, node: { tree: k, t, rank, tone: talentTone(ch, t, a.ds), icon: talentIcon(a.ds, t) },
|
|
967
|
+
detail: () => talentLines(a, t, rank, canSpend(ch, t.id, a.ds)),
|
|
968
|
+
act: () => a.result(a.g.spendTalent(t.id), `You learn ${t.name} (${(ch.talents?.[t.id] ?? 0) + 1}/${t.maxRank}).`),
|
|
969
|
+
};
|
|
970
|
+
}));
|
|
971
|
+
}
|
|
827
972
|
const rows = [{ text: ch.level < 10 ? dim('Talents unlock at level 10.') : `${plural(free, 'point')} to spend`, header: true }];
|
|
828
973
|
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) });
|
|
829
974
|
for (const tree of a.ds.talentTrees(ch.cls)) {
|
|
@@ -835,6 +980,7 @@ function talentsView(app) {
|
|
|
835
980
|
const paint = rank >= t.maxRank ? brightYellow : can.ok ? green : dim;
|
|
836
981
|
rows.push({
|
|
837
982
|
text: `${paint(t.name)} ${dim(`${rank}/${t.maxRank}`)}${t.prereq ? dim(` needs ${a.ds.talent(t.prereq.talentId)?.name ?? '?'}`) : ''}`, data: t,
|
|
983
|
+
icon: talentIcon(a.ds, t), sub: dim(`tier ${t.tier + 1}`),
|
|
838
984
|
detail: () => talentLines(a, t, rank, can),
|
|
839
985
|
act: () => a.result(a.g.spendTalent(t.id), `You learn ${t.name} (${(ch.talents?.[t.id] ?? 0) + 1}/${t.maxRank}).`),
|
|
840
986
|
});
|
|
@@ -846,9 +992,14 @@ function talentsView(app) {
|
|
|
846
992
|
}
|
|
847
993
|
|
|
848
994
|
function talentLines(app, t, rank, can) {
|
|
995
|
+
return withIcon(talentIcon(app.ds, t), talentLinesText(app, t, rank, can));
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function talentLinesText(app, t, rank, can) {
|
|
849
999
|
const out = [bold(t.name) + dim(` tier ${t.tier + 1}, rank ${rank}/${t.maxRank}`)];
|
|
850
|
-
|
|
851
|
-
if (rank
|
|
1000
|
+
// The pane wraps the descriptions to its own width (beside the icon while they share its rows).
|
|
1001
|
+
if (rank > 0) out.push('', cyan('Current rank'), t.ranks[rank - 1]?.description ?? '');
|
|
1002
|
+
if (rank < t.maxRank) out.push('', cyan(rank ? 'Next rank' : 'Rank 1'), t.ranks[rank]?.description ?? '');
|
|
852
1003
|
out.push('', can.ok ? green('Enter to spend a point') : red(can.reason ?? 'Cannot spend here yet'));
|
|
853
1004
|
if (rank > 0 && canRefund(app.ch, t.id, app.ds).ok) out.push(dim('u refunds a point'));
|
|
854
1005
|
return out;
|
|
@@ -861,7 +1012,7 @@ function travelView(app) {
|
|
|
861
1012
|
id: 'travel', title: 'Travel', hint: 'Enter: go',
|
|
862
1013
|
rows(a) {
|
|
863
1014
|
const g = a.g, ds = a.ds, z = a.zone(), ch = a.ch;
|
|
864
|
-
const rows = [{ text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }, { text: '', header: true }, { text: 'On foot', header: true }];
|
|
1015
|
+
const rows = [...journeyRows(a), { text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }, { text: '', header: true }, { text: 'On foot', header: true }];
|
|
865
1016
|
for (const id of z?.neighbors ?? []) { const d = ds.zone(id); if (d) rows.push({ text: `${d.name} ${dim(`L${d.levelRange[0]}-${d.levelRange[1]}`)}`, detail: () => travelPreviewLines(a, d), act: () => travelTo(a, d) }); }
|
|
866
1017
|
const taxi = g.taxiOptions();
|
|
867
1018
|
if (taxi.length) {
|
|
@@ -874,12 +1025,12 @@ function travelView(app) {
|
|
|
874
1025
|
rows.push({
|
|
875
1026
|
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.'),
|
|
876
1027
|
detail: () => [bold('Hearthstone'), bind ? `Bound at ${bind.name ?? ds.zone(bind.zone)?.name}` : 'Not bound', cd > 0 ? red(`Ready in ${secondsText(cd)}`) : green('Ready')],
|
|
877
|
-
act: () => { const r = g.castHearth(); if (a.result(r)) { a.vendor = null; afterDeparture(a, () => `You hearth to ${a.zone()?.name}.`); a
|
|
1028
|
+
act: () => { const r = g.castHearth(); if (a.result(r)) { a.vendor = null; afterDeparture(a, () => `You hearth to ${a.zone()?.name}.`); landOnTab(a); } },
|
|
878
1029
|
});
|
|
879
1030
|
const ports = g.classTravelOptions();
|
|
880
1031
|
if (ports.length) {
|
|
881
1032
|
rows.push({ text: '', header: true }, { text: 'Teleports', header: true });
|
|
882
|
-
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}.`); a
|
|
1033
|
+
for (const p of ports) rows.push({ text: `${p.name} → ${ds.zone(p.toZone)?.name ?? p.toZone}${p.reagents.some((r) => r.have < r.count) ? red(' no reagent') : ''}`, act: () => { const r = g.castTeleport(p.spellId); if (a.result(r)) { afterDeparture(a, () => `You teleport to ${a.zone()?.name}.`); landOnTab(a); } } });
|
|
883
1034
|
}
|
|
884
1035
|
rows.push(...portalRows(a));
|
|
885
1036
|
return rows;
|
|
@@ -914,9 +1065,32 @@ function weaponSkillRows(a, L) {
|
|
|
914
1065
|
return rows;
|
|
915
1066
|
}
|
|
916
1067
|
|
|
1068
|
+
/**
|
|
1069
|
+
* Your character as a sprite for a pane: the form or costume you wear when the creature pack has it, else
|
|
1070
|
+
* you in your gear (paperdoll.mjs), else your bare race and gender body. `label` names the form when that
|
|
1071
|
+
* is what shows; null without art.
|
|
1072
|
+
*/
|
|
1073
|
+
export function playerArt(app, maxCols, maxRows) {
|
|
1074
|
+
const ch = app.ch;
|
|
1075
|
+
const worn = wornDisplay(ch, app.ds, Date.now());
|
|
1076
|
+
const wornArt = worn?.displayId ? creatureArt(worn.displayId, maxCols, maxRows) : null;
|
|
1077
|
+
if (wornArt) return { lines: wornArt, label: worn.label };
|
|
1078
|
+
const geared = paperdollArt(ch, maxCols, maxRows);
|
|
1079
|
+
if (geared) return { lines: geared, label: null };
|
|
1080
|
+
const own = creatureArt(nativeDisplayId(ch.race, ch.gender), maxCols, maxRows);
|
|
1081
|
+
return own ? { lines: own, label: null } : null;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
917
1084
|
function charView(app) {
|
|
918
1085
|
return {
|
|
919
|
-
id: 'char', title: 'Character', hint: '',
|
|
1086
|
+
id: 'char', title: 'Character', hint: '',
|
|
1087
|
+
/** The detail pane holds your character: the body of your race and gender, or the form you are in. */
|
|
1088
|
+
detail(a) {
|
|
1089
|
+
const L = a.layout(), ch = a.ch;
|
|
1090
|
+
const art = playerArt(a, L.detailW - 2, L.mainH - 2);
|
|
1091
|
+
if (!art) return [];
|
|
1092
|
+
return [`${bold(ch.name)} ${dim(`${raceName(ch.race)} ${className(ch.cls)}${art.label ? `, ${art.label}` : ''}`)}`, '', ...art.lines];
|
|
1093
|
+
},
|
|
920
1094
|
rows(a) {
|
|
921
1095
|
const ch = a.ch, ds = a.ds;
|
|
922
1096
|
const st = effectiveStats(ch, ds);
|
|
@@ -1057,6 +1231,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1057
1231
|
const usedBy = uses.get(o.itemEntry);
|
|
1058
1232
|
rows.push({
|
|
1059
1233
|
text: `${ok ? name : dim(name)} ${yellow(price)}${o.stockRemaining != null ? dim(` ${o.stockRemaining} left`) : ''}${it?.requiredLevel > a.ch.level ? red(` L${it.requiredLevel}`) : ''}${usedBy ? cyan(' reagent') : ''}${why}`, data: o,
|
|
1234
|
+
icon: it?.icon, sub: dim([it?.typeName, it?.slot].filter(Boolean).join(' · ')),
|
|
1060
1235
|
detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${moneyText(o.basePricePerPurchase)} before reputation)`)] : []),
|
|
1061
1236
|
...(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.')] : [])]),
|
|
1062
1237
|
act: () => buy(a, o, 1),
|
|
@@ -1072,6 +1247,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1072
1247
|
const total = (it?.sellPrice ?? 0) * s.count;
|
|
1073
1248
|
rows.push({
|
|
1074
1249
|
text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${total ? yellow(moneyText(total)) : dim('no value')}`, data: s,
|
|
1250
|
+
icon: it?.icon, sub: dim(it?.typeName ?? ''),
|
|
1075
1251
|
detail: () => itemLines(a, it, ['', dim('Enter: sell the stack (you can buy it back on the Buyback tab) d: destroy')]),
|
|
1076
1252
|
act: () => sellStack(a, slot),
|
|
1077
1253
|
});
|
|
@@ -1118,8 +1294,10 @@ function buy(app, offer, n) {
|
|
|
1118
1294
|
|
|
1119
1295
|
export function trainerDialog(app, trainer) {
|
|
1120
1296
|
const known = () => new Set(app.g.state.spellbook.known);
|
|
1297
|
+
// Weapon masters list every class's weapons; hide the rows the class/race gate would refuse.
|
|
1298
|
+
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)));
|
|
1121
1299
|
const learnable = () => trainer.spells
|
|
1122
|
-
.filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell))
|
|
1300
|
+
.filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts))
|
|
1123
1301
|
.map((ts) => ({ ts, sp: app.ds.spell(ts.spell) })).filter((x) => x.sp)
|
|
1124
1302
|
.sort((x, y) => x.ts.reqLevel - y.ts.reqLevel || x.sp.name.localeCompare(y.sp.name));
|
|
1125
1303
|
return {
|
|
@@ -1133,11 +1311,12 @@ export function trainerDialog(app, trainer) {
|
|
|
1133
1311
|
const can = ts.cost <= a.ch.gold;
|
|
1134
1312
|
rows.push({
|
|
1135
1313
|
text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${yellow(moneyText(ts.cost) || 'free')}`, data: sp,
|
|
1314
|
+
icon: sp.icon, sub: dim(`level ${ts.reqLevel}${sp.cost ? ` ${sp.cost} ${sp.powerType}` : ''}`),
|
|
1136
1315
|
detail: () => spellLines(a, sp, ['', `Cost: ${yellow(moneyText(ts.cost) || 'free')}`]),
|
|
1137
1316
|
act: () => a.result(a.g.learnFromTrainer(trainer.entry, sp.id), `You have learned ${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''}.`),
|
|
1138
1317
|
});
|
|
1139
1318
|
}
|
|
1140
|
-
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);
|
|
1319
|
+
const later = trainer.spells.filter((ts) => ts.reqLevel > a.ch.level && !known().has(ts.spell) && fits(ts)).map((ts) => ({ ts, sp: a.ds.spell(ts.spell) })).filter((x) => x.sp).sort((x, y) => x.ts.reqLevel - y.ts.reqLevel).slice(0, 12);
|
|
1141
1320
|
if (later.length) {
|
|
1142
1321
|
rows.push({ text: '', header: true }, { text: 'Coming up', header: true });
|
|
1143
1322
|
for (const { sp, ts } of later) rows.push({ text: dim(`${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''} level ${ts.reqLevel} ${moneyText(ts.cost)}`), header: true });
|
|
@@ -1151,11 +1330,13 @@ export function innDialog(app, inn) {
|
|
|
1151
1330
|
return {
|
|
1152
1331
|
id: `inn-${inn.entry}`, title: inn.name, hint: 'Enter: choose', noDetail: true,
|
|
1153
1332
|
rows(a) {
|
|
1154
|
-
const bound = a.ch.hearthBind?.name === inn.name;
|
|
1333
|
+
const bound = a.ch.hearthBind?.name === inn.name, wares = a.ds.vendor(inn.entry);
|
|
1155
1334
|
return [
|
|
1156
|
-
{ text: `${bold(inn.name)} ${dim('<Innkeeper>')}`, header: true },
|
|
1335
|
+
{ text: `${bold(inn.name)} ${dim('<Innkeeper>')}`, header: true },
|
|
1336
|
+
// Rested xp needs no button: it builds here in game, and at the full offline rate after logging out here.
|
|
1337
|
+
{ 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 },
|
|
1338
|
+
...(wares ? [{ text: 'Browse wares', act: () => a.push(vendorDialog(a, wares)) }] : []),
|
|
1157
1339
|
{ 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}.`); } },
|
|
1158
|
-
{ 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()}`)); } },
|
|
1159
1340
|
{ text: dim('Goodbye'), act: () => a.pop() },
|
|
1160
1341
|
];
|
|
1161
1342
|
},
|