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/tuiCity.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// City services: the bank, the auction house and the mailbox. Same view shape as tuiViews.mjs; every
|
|
2
2
|
// change is a Game facade call. Where they open follows phonecraft's CITY_PANEL rule: the auction
|
|
3
3
|
// house in the six capitals, the bank there plus the goblin and Crossroads banker towns.
|
|
4
|
-
import { bold, dim, cyan, yellow, green, red, qualityPaint, fit, secondsText, plural, itemText } from './text.mjs';
|
|
4
|
+
import { bold, dim, cyan, yellow, green, red, inverse, vis, useColor, qualityPaint, fit, secondsText, plural, itemText } from './text.mjs';
|
|
5
5
|
import { itemLines } from './tuiViews.mjs';
|
|
6
6
|
import { moneyText } from '../domain/economy/money.ts';
|
|
7
7
|
import { bagCapacity } from '../domain/items/equipment.ts';
|
|
@@ -116,17 +116,100 @@ export function bankDialog(app, startMode = 'deposit') {
|
|
|
116
116
|
|
|
117
117
|
// ---- auction house ------------------------------------------------------------------------------
|
|
118
118
|
|
|
119
|
+
// Browse categories: ItemClass.dbc in AuctionFrame.lua order, as phonecraft's auctionModel.ts. Subclass names are
|
|
120
|
+
// ItemSubClass.dbc; the TBC-era consumable and trade goods subclasses the item table carries get their names too.
|
|
121
|
+
const AH_CLASSES = [
|
|
122
|
+
[2, 'Weapons'], [4, 'Armor'], [1, 'Container'], [0, 'Consumable'], [7, 'Trade Goods'],
|
|
123
|
+
[6, 'Projectile'], [11, 'Quiver'], [9, 'Recipe'], [5, 'Reagent'], [15, 'Miscellaneous'],
|
|
124
|
+
];
|
|
125
|
+
const AH_SUBCLASSES = {
|
|
126
|
+
0: ['Consumable', 'Potion', 'Elixir', 'Flask', 'Scroll', 'Food & Drink', 'Item Enhancement', 'Bandage', 'Other'],
|
|
127
|
+
1: ['Bag', 'Soul Bag', 'Herb Bag', 'Enchanting Bag', 'Engineering Bag'],
|
|
128
|
+
2: { 0: 'One-Handed Axes', 1: 'Two-Handed Axes', 2: 'Bows', 3: 'Guns', 4: 'One-Handed Maces', 5: 'Two-Handed Maces', 6: 'Polearms', 7: 'One-Handed Swords', 8: 'Two-Handed Swords', 10: 'Staves', 13: 'Fist Weapons', 14: 'Miscellaneous', 15: 'Daggers', 16: 'Thrown', 17: 'Spears', 18: 'Crossbows', 19: 'Wands', 20: 'Fishing Poles' },
|
|
129
|
+
4: { 0: 'Miscellaneous', 1: 'Cloth', 2: 'Leather', 3: 'Mail', 4: 'Plate', 6: 'Shields', 7: 'Librams', 8: 'Idols', 9: 'Totems' },
|
|
130
|
+
5: ['Reagent'],
|
|
131
|
+
6: { 2: 'Arrows', 3: 'Bullets' },
|
|
132
|
+
7: ['Trade Goods', 'Parts', 'Explosives', 'Devices', 'Jewelcrafting', 'Cloth', 'Leather', 'Metal & Stone', 'Meat', 'Herbs', 'Elemental', 'Other', 'Enchanting'],
|
|
133
|
+
9: ['Books', 'Leatherworking', 'Tailoring', 'Engineering', 'Blacksmithing', 'Cooking', 'Alchemy', 'First Aid', 'Enchanting', 'Fishing'],
|
|
134
|
+
11: { 2: 'Quivers', 3: 'Ammo Pouches' },
|
|
135
|
+
15: ['Junk'],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** [category, subcategory] of an item: a browse class (15 catches the rest) and a subclass name. */
|
|
139
|
+
function ahCategory(it) {
|
|
140
|
+
const cls = AH_CLASSES.some(([c]) => c === it?.itemClass) ? it.itemClass : 15;
|
|
141
|
+
const sub = it?.itemSubclass ?? it?.weaponSubclass ?? it?.armorSubclass ?? 0;
|
|
142
|
+
return [cls, AH_SUBCLASSES[cls]?.[sub] ?? 'Other'];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One line of tabs, the picked one inverse; when it does not fit it scrolls to keep the pick in view. A strip
|
|
147
|
+
* under the cursor is drawn inverse whole, so there the pick is bracketed instead.
|
|
148
|
+
*/
|
|
149
|
+
function tabStrip(labels, at, width, focused = false) {
|
|
150
|
+
const cells = labels.map((l, i) => (i === at ? (useColor && !focused ? inverse(` ${l} `) : focused ? bold(`[${l}]`) : `[${l}]`) : ` ${l} `));
|
|
151
|
+
let from = 0, to = cells.length;
|
|
152
|
+
const len = () => cells.slice(from, to).reduce((n, c) => n + vis(c), 0) + (from > 0 ? 2 : 0) + (to < cells.length ? 2 : 0);
|
|
153
|
+
while (len() > width && to - from > 1) { if (at - from > to - 1 - at) from++; else to--; }
|
|
154
|
+
return `${from > 0 ? dim('‹ ') : ''}${cells.slice(from, to).join('')}${to < cells.length ? dim(' ›') : ''}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
119
157
|
export function auctionDialog(app) {
|
|
120
158
|
app.g.refillAuctions(Date.now());
|
|
121
159
|
let mode = 'browse';
|
|
122
160
|
let filter = '';
|
|
161
|
+
let cat = 'all', sub = 'all';
|
|
123
162
|
const MODES = ['browse', 'sell', 'mine'];
|
|
124
|
-
|
|
163
|
+
// The category and subcategory strips are cursor rows: Up from the listings lands on them, Left Right turns them.
|
|
164
|
+
let lastRows = [];
|
|
165
|
+
const stripAt = () => lastRows[view.cursor]?.data?.strip;
|
|
166
|
+
const hint = () => {
|
|
167
|
+
if (mode === 'browse') {
|
|
168
|
+
const s = stripAt(), hasSub = lastRows.some((r) => r.data?.strip === 'sub');
|
|
169
|
+
if (s === 'cat') return `Left Right: category Down: ${hasSub ? 'subcategory' : 'listings'} Tab: sell`;
|
|
170
|
+
if (s === 'sub') return 'Left Right: subcategory Up: category Down: listings Tab: sell';
|
|
171
|
+
return `Enter: buyout Left Right: category Up to the tabs: ${hasSub ? 'subcategory' : 'category'} Tab: sell`;
|
|
172
|
+
}
|
|
173
|
+
return { sell: 'Enter: list for sale Tab: my auctions', mine: 'Enter: cancel listing Tab: browse' }[mode];
|
|
174
|
+
};
|
|
125
175
|
const unit = (x) => Math.round(x.buyoutCopper / x.quantity);
|
|
176
|
+
// Back to the top of the list; in browse the cursor goes to the first listing, past the strips.
|
|
177
|
+
let toList = true;
|
|
178
|
+
const top = () => { toList = true; view.scroll = 0; };
|
|
179
|
+
// The listings the search leaves, the categories they fall in (the picked one stays even when it empties)
|
|
180
|
+
// and the subcategories of the picked category, each with its count.
|
|
181
|
+
const browse = (a, now) => {
|
|
182
|
+
let all = a.g.browseAuctions(now).map((au) => { const it = a.ds.item(au.item); return { au, it, c: ahCategory(it) }; });
|
|
183
|
+
if (filter) all = all.filter((x) => (x.it?.name ?? '').toLowerCase().includes(filter));
|
|
184
|
+
const count = (pred) => all.filter(pred).length;
|
|
185
|
+
const cats = [{ key: 'all', label: 'All', n: all.length }];
|
|
186
|
+
for (const [c, label] of AH_CLASSES) { const n = count((x) => x.c[0] === c); if (n || c === cat) cats.push({ key: c, label, n }); }
|
|
187
|
+
const inCat = cat === 'all' ? all : all.filter((x) => x.c[0] === cat);
|
|
188
|
+
const subs = [{ key: 'all', label: 'All', n: inCat.length }];
|
|
189
|
+
for (const x of inCat) { const s = subs.find((y) => y.key === x.c[1]); if (s) s.n++; else subs.push({ key: x.c[1], label: x.c[1], n: 1 }); }
|
|
190
|
+
if (sub !== 'all' && !subs.some((s) => s.key === sub)) subs.push({ key: sub, label: sub, n: 0 });
|
|
191
|
+
const order = Object.values(AH_SUBCLASSES[cat] ?? {}), rank = (s) => (s.key === 'all' ? -1 : order.includes(s.key) ? order.indexOf(s.key) : order.length);
|
|
192
|
+
subs.sort((x, y) => rank(x) - rank(y));
|
|
193
|
+
const list = sub === 'all' ? inCat : inCat.filter((x) => x.c[1] === sub);
|
|
194
|
+
return { cats, subs, list };
|
|
195
|
+
};
|
|
196
|
+
// Turn a strip; on a strip the cursor stays put so it can keep turning, from a listing it goes back to the top.
|
|
197
|
+
const step = (a, which, d) => {
|
|
198
|
+
const b = browse(a, Date.now());
|
|
199
|
+
const opts = which === 'cat' ? b.cats : b.subs;
|
|
200
|
+
if (opts.length < 2) return;
|
|
201
|
+
const i = opts.findIndex((o) => o.key === (which === 'cat' ? cat : sub));
|
|
202
|
+
const next = opts[(i + d + opts.length) % opts.length].key;
|
|
203
|
+
if (which === 'cat') { cat = next; sub = 'all'; } else sub = next;
|
|
204
|
+
if (stripAt()) view.scroll = 0; else top();
|
|
205
|
+
};
|
|
126
206
|
const view = {
|
|
127
|
-
id: 'auction', title: 'Auction House',
|
|
207
|
+
id: 'auction', title: 'Auction House',
|
|
208
|
+
get hint() { return hint(); },
|
|
128
209
|
keys: {
|
|
129
|
-
s: { hint: 'search', fn: (a) => { if (mode !== 'browse') return; a.prompt('Search: ', (t) => { filter = t.trim().toLowerCase();
|
|
210
|
+
s: { hint: 'search', fn: (a) => { if (mode !== 'browse') return; a.prompt('Search: ', (t) => { filter = t.trim().toLowerCase(); top(); a.render(); }, filter); } },
|
|
211
|
+
'[': { fn: (a) => { if (mode === 'browse') step(a, 'sub', -1); } },
|
|
212
|
+
']': { fn: (a) => { if (mode === 'browse') step(a, 'sub', +1); } },
|
|
130
213
|
b: { hint: 'bid', fn: (a, row) => {
|
|
131
214
|
if (mode !== 'browse' || !row?.data?.auction) return;
|
|
132
215
|
const au = row.data.auction, min = minNextBid(au);
|
|
@@ -140,25 +223,39 @@ export function auctionDialog(app) {
|
|
|
140
223
|
m: { hint: 'mailbox', fn: (a) => a.push(mailDialog(a)) },
|
|
141
224
|
},
|
|
142
225
|
onKey(a, k) {
|
|
143
|
-
if (k
|
|
144
|
-
|
|
226
|
+
if (mode === 'browse' && (k === 'left' || k === 'right')) { step(a, stripAt() ?? 'cat', k === 'right' ? +1 : -1); return true; }
|
|
227
|
+
if (k !== 'tab' && k !== 'shift-tab') return false;
|
|
228
|
+
mode = MODES[(MODES.indexOf(mode) + (k === 'tab' ? 1 : MODES.length - 1)) % MODES.length];
|
|
145
229
|
if (mode === 'browse') a.g.refillAuctions(Date.now());
|
|
146
|
-
|
|
230
|
+
top();
|
|
147
231
|
return true;
|
|
148
232
|
},
|
|
149
233
|
rows(a) {
|
|
150
234
|
const now = Date.now(), ds = a.ds;
|
|
151
235
|
const unread = a.g.unreadMailCount(now);
|
|
236
|
+
const L = a.layout(), width = (L.detailW ? L.listW : L.W) - 2;
|
|
237
|
+
const modeLabels = MODES.map((m) => ({ browse: 'Browse', sell: 'Sell', mine: 'My auctions' }[m]));
|
|
152
238
|
const rows = [
|
|
153
|
-
{ text: `${bold('Auction House')}
|
|
154
|
-
{ text: '', header: true },
|
|
239
|
+
{ text: `${bold('Auction House')} ${tabStrip(modeLabels, MODES.indexOf(mode), 40)} ${yellow(moneyText(a.ch.gold) || '0c')}${unread ? yellow(` ${unread} new mail`) : ''}`, header: true },
|
|
155
240
|
];
|
|
156
241
|
if (mode === 'browse') {
|
|
157
|
-
|
|
158
|
-
|
|
242
|
+
const b = browse(a, now);
|
|
243
|
+
const strip = (which, opts, key, label) => {
|
|
244
|
+
const focused = !toList && view.cursor === rows.length;
|
|
245
|
+
rows.push({
|
|
246
|
+
text: tabStrip(opts.map((o) => `${o.label} ${o.n}`), opts.findIndex((o) => o.key === key), width - 1, focused), data: { strip: which },
|
|
247
|
+
detail: () => [bold(label), '', dim('Left Right: pick one'), dim(`Down${which === 'cat' && rows.some((r) => r.data?.strip === 'sub') ? ': subcategories' : ' or Enter: the listings'}`)],
|
|
248
|
+
act: (a2) => a2.moveCursor(view, a2.rowsOf(view), +1),
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
strip('cat', b.cats, cat, 'Category');
|
|
252
|
+
if (cat !== 'all' && b.subs.length > 2) strip('sub', b.subs, sub, 'Subcategory');
|
|
253
|
+
rows.push({ text: '', header: true });
|
|
254
|
+
const list = b.list.map((x) => x.au);
|
|
159
255
|
list.sort((x, y) => (ds.item(x.item)?.name ?? '').localeCompare(ds.item(y.item)?.name ?? '') || unit(x) - unit(y));
|
|
160
|
-
|
|
161
|
-
|
|
256
|
+
const where = cat === 'all' ? '' : `${b.cats.find((c) => c.key === cat)?.label}${sub === 'all' ? '' : ` › ${sub}`}: `;
|
|
257
|
+
rows.push({ text: `${where}${filter ? `"${filter}": ` : ''}${plural(list.length, 'listing')}${filter ? dim(' s: change search') : dim(' s: search by name')}`, header: true });
|
|
258
|
+
if (!list.length) rows.push({ text: dim(filter || cat !== 'all' ? 'Nothing listed here.' : 'Nothing on the boards right now.'), header: true });
|
|
162
259
|
for (const au of list) {
|
|
163
260
|
const it = ds.item(au.item);
|
|
164
261
|
const can = au.buyoutCopper <= a.ch.gold;
|
|
@@ -171,6 +268,7 @@ export function auctionDialog(app) {
|
|
|
171
268
|
});
|
|
172
269
|
}
|
|
173
270
|
} else if (mode === 'sell') {
|
|
271
|
+
rows.push({ text: '', header: true });
|
|
174
272
|
const st = a.g.state.inventory.stacks;
|
|
175
273
|
rows.push({ text: dim('Pick something from your bags to put up for auction.'), header: true });
|
|
176
274
|
if (!st.length) rows.push({ text: dim('Your bags are empty.'), header: true });
|
|
@@ -185,6 +283,7 @@ export function auctionDialog(app) {
|
|
|
185
283
|
});
|
|
186
284
|
}
|
|
187
285
|
} else {
|
|
286
|
+
rows.push({ text: '', header: true });
|
|
188
287
|
const own = a.g.ownAuctions(), bids = a.g.myBidAuctions();
|
|
189
288
|
const mail = a.g.listMail(now);
|
|
190
289
|
rows.push({ text: `Mailbox: ${mail.length ? `${plural(mail.length, 'letter')} waiting` : 'empty'}${dim(' (sales and refunds arrive here)')}`, act: () => a.push(mailDialog(a)), data: {} });
|
|
@@ -206,6 +305,12 @@ export function auctionDialog(app) {
|
|
|
206
305
|
rows.push({ text: ` ${qualityPaint(it?.quality)(it?.name ?? au.item)}${au.quantity > 1 ? ` x${au.quantity}` : ''} ${dim('your bid')} ${moneyText(au.bidCopper)} ${timeLeft(au, now)}`, header: true });
|
|
207
306
|
}
|
|
208
307
|
}
|
|
308
|
+
if (toList) {
|
|
309
|
+
toList = false;
|
|
310
|
+
const first = mode === 'browse' ? rows.findIndex((r) => r.data?.auction) : -1;
|
|
311
|
+
view.cursor = first >= 0 ? first : 0;
|
|
312
|
+
}
|
|
313
|
+
lastRows = rows;
|
|
209
314
|
return rows;
|
|
210
315
|
},
|
|
211
316
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The Companions tab: your party and bench, hiring at an inn, one page per companion (role, gear, bags,
|
|
2
2
|
// professions) and their gathering trips, and the squad mode that decides who fights beside you. A trimmed
|
|
3
3
|
// port of phonecraft's companionsPanel.ts, companionMissionsDesk.ts and squadModePicker.ts.
|
|
4
|
-
import { bold, dim, cyan, yellow, green, red, qualityPaint, fit, plural, itemText } from './text.mjs';
|
|
4
|
+
import { bold, dim, cyan, yellow, green, red, qualityPaint, fit, plural, itemText, vis, wrapLines } from './text.mjs';
|
|
5
5
|
import { itemLines } from './tuiViews.mjs';
|
|
6
6
|
import { recipeBook, openSendGoods, companionsOf } from './tuiProfessions.mjs';
|
|
7
7
|
import { SQUAD_MODES, SQUAD_MODE_LABEL, squadModeOf, setSquadMode } from './squad.mjs';
|
|
@@ -10,6 +10,7 @@ import { barsDialog, companionBarsOwner } from './tuiBars.mjs';
|
|
|
10
10
|
import { enchantLines } from './tuiGear.mjs';
|
|
11
11
|
import { moneyText } from '../domain/economy/money.ts';
|
|
12
12
|
import { className, raceName } from '../domain/core/ids.ts';
|
|
13
|
+
import { paperdollArt } from './paperdoll.mjs';
|
|
13
14
|
import { bagCapacity, EQUIP_SLOTS, SLOT_LABEL, itemSlotTargets } from '../domain/items/equipment.ts';
|
|
14
15
|
import { equippedEntry } from '../domain/items/itemInstance.ts';
|
|
15
16
|
import { companionCharacter, companionLevel, hireSkillMap, MAX_COMPANIONS, SQUAD_ROLES } from '../domain/bots/squad.ts';
|
|
@@ -76,7 +77,15 @@ function statLines(app, char) {
|
|
|
76
77
|
|
|
77
78
|
function statRows(app, char, note) {
|
|
78
79
|
const lines = statLines(app, char);
|
|
79
|
-
return lines.map((l) => ({ text: l.trim(), detail: () => [bold('Stats'), '', ...lines, '', dim(note)] }));
|
|
80
|
+
return lines.map((l) => ({ text: l.trim(), detail: () => withArt(app, char, [bold('Stats'), '', ...lines, '', dim(note)]) }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The text, then `subject` (a companion, a candidate's body, any { race, gender, name, equipped }) drawn in their gear under it when the pane has room. */
|
|
84
|
+
function withArt(app, subject, text) {
|
|
85
|
+
const L = app.layout(), w = L.detailW - 2;
|
|
86
|
+
const used = text.reduce((n, line) => n + (vis(line) > w ? wrapLines(line, w).length : 1), 0); // as the pane wraps them
|
|
87
|
+
const art = paperdollArt(subject, w, L.mainH - used - 1);
|
|
88
|
+
return art ? [...text, '', ...art] : text;
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
function wornLines(app, gear) {
|
|
@@ -134,7 +143,7 @@ export function companionsView(app) {
|
|
|
134
143
|
const rows = [H(`Party ${squad.length}/${MAX_COMPANIONS} ${dim(`trip energy ${energy}/${MAX_COMPANION_ENERGY}`)} ${yellow(moneyText(a.ch.gold) || '0c')}`), squadModeRow(a)];
|
|
135
144
|
const row = (c) => ({
|
|
136
145
|
text: `${fit(c.name, 12)} ${fit(dim(`L${companionLevel(c, a.ch.level)} ${className(c.cls)}`), 12)} ${fit(roleText(c.role), 6)} ${c.mission ? yellow('away ') : ''}${profTags(compProfLines(a, c))}`,
|
|
137
|
-
detail: () => [...compLines(a, c), '', dim('Enter: open their page')],
|
|
146
|
+
detail: () => withArt(a, c, [...compLines(a, c), '', dim('Enter: open their page')]), // them in their gear
|
|
138
147
|
act: () => a.push(companionDialog(a, c.id)),
|
|
139
148
|
});
|
|
140
149
|
if (!squad.length && !bench.length) rows.push(H(''), H(dim(' You travel alone. Innkeepers hire out companions: they fight beside you, carry and craft.')));
|
|
@@ -249,7 +258,7 @@ function hireDialog(app, results) {
|
|
|
249
258
|
rows.push({
|
|
250
259
|
text: `${fit(b.name, 12)} ${fit(dim(`L${b.level} ${className(b.cls)}`), 12)} ${fit(c.specRole ? roleText(c.specRole) : '', 6)} ${fit(hireText(a, c, true), 7)} ${profTags(candProfLines(c))}`,
|
|
251
260
|
data: b.id,
|
|
252
|
-
detail: () => [...candLines(a, c), '', dim('Enter: their page h: hire')],
|
|
261
|
+
detail: () => withArt(a, candCharacter(a, c), [...candLines(a, c), '', dim('Enter: their page h: hire')]),
|
|
253
262
|
act: () => a.push(candidateDialog(a, b.id)),
|
|
254
263
|
});
|
|
255
264
|
}
|
|
@@ -266,7 +275,7 @@ function candidateDialog(app, botId) {
|
|
|
266
275
|
rows(a) {
|
|
267
276
|
const c = findCand(a, botId);
|
|
268
277
|
if (!c) { setTimeout(() => { if (a.top()?.id === viewId) a.pop(); }, 0); return [H('They are no longer looking for work.')]; }
|
|
269
|
-
const b = c.bot, ds = a.ds;
|
|
278
|
+
const b = c.bot, ds = a.ds, body = candCharacter(a, c);
|
|
270
279
|
const rows = [H(`${bold(b.name)} ${dim(`L${b.level} ${raceName(b.race)} ${className(b.cls)}`)} ${c.specName ?? ''} ${hireText(a, c)}`), H('')];
|
|
271
280
|
|
|
272
281
|
// Hire: their spec's role first; any role works, it rebuilds talents and rotation.
|
|
@@ -275,9 +284,9 @@ function candidateDialog(app, botId) {
|
|
|
275
284
|
for (const role of roles) {
|
|
276
285
|
rows.push({
|
|
277
286
|
text: `Hire as ${roleText(role)} for ${hireText(a, c)}${role === c.specRole ? dim(' their spec') : ''}`,
|
|
278
|
-
detail: () => [bold(`Hire ${b.name} as ${role}`),
|
|
287
|
+
detail: () => withArt(a, body, [bold(`Hire ${b.name} as ${role}`), `Cost: ${hireText(a, c)} you have ${yellow(moneyText(a.ch.gold) || '0c')}`,
|
|
279
288
|
role === c.specRole ? dim(`They already play ${c.specName}.`) : dim('They respec for the role: talents and rotation rebuild for free.'),
|
|
280
|
-
(a.g.state.squad ?? []).length >= MAX_COMPANIONS ? yellow('Your party is full: they start on the bench.') :
|
|
289
|
+
...((a.g.state.squad ?? []).length >= MAX_COMPANIONS ? [yellow('Your party is full: they start on the bench.')] : []), dim('A released hire refunds the price.')]),
|
|
281
290
|
act: () => { if (hireAs(a, c, role)) a.pop(); },
|
|
282
291
|
});
|
|
283
292
|
}
|
|
@@ -293,7 +302,7 @@ function candidateDialog(app, botId) {
|
|
|
293
302
|
});
|
|
294
303
|
}
|
|
295
304
|
|
|
296
|
-
rows.push(H(''), H('Stats'), ...statRows(a,
|
|
305
|
+
rows.push(H(''), H('Stats'), ...statRows(a, body, 'With the gear they arrive in, on their own spec.'));
|
|
297
306
|
|
|
298
307
|
rows.push(H(''), H(`Wearing ${dim(c.gearProfile ? `${gearProfileLabel(c.gearProfile)} kit` : '')}`));
|
|
299
308
|
for (const slot of EQUIP_SLOTS) {
|
|
@@ -330,15 +339,16 @@ export function companionDialog(app, id) {
|
|
|
330
339
|
rows.push({ text: yellow(awayText(a, comp)), detail: () => missionStatusLines(a, comp), act: () => a.push(missionDialog(a, id, comp.mission.skill)) });
|
|
331
340
|
}
|
|
332
341
|
rows.push(H(''));
|
|
333
|
-
|
|
342
|
+
const note = (title, ...lines) => withArt(a, comp, [bold(title), '', ...lines]);
|
|
343
|
+
rows.push({ text: `Role: ${roleText(comp.role)}`, detail: () => note('Role', 'Tank, healer or damage. Changing it is free and rebuilds their talents and rotation for the role.'),
|
|
334
344
|
act: () => a.pick(`${comp.name}: what role?`, SQUAD_ROLES.map((r) => ({ label: roleText(r), value: r })), (r) => a.result(g.setCompanionRole(id, r), `${comp.name} is now your ${r}.`)) });
|
|
335
345
|
rows.push(seated
|
|
336
|
-
? { text: 'Move to the bench', detail: () =>
|
|
337
|
-
: { text: green('Seat in the party'), detail: () =>
|
|
338
|
-
rows.push({ text: 'Put on their best gear', detail: () =>
|
|
339
|
-
rows.push({ text: 'Give them something from your bags…', detail: () =>
|
|
346
|
+
? { text: 'Move to the bench', detail: () => note('Bench', 'They stay yours with their gear, level and bags, and come back free from any inn or right here.'), act: () => a.result(g.dismissCompanion(id), `${comp.name} waits on the bench.`) }
|
|
347
|
+
: { text: green('Seat in the party'), detail: () => note('Party', `Up to ${MAX_COMPANIONS} companions travel with you.`), act: () => a.result(g.activateCompanion(id), `${comp.name} joins the party.`) });
|
|
348
|
+
rows.push({ text: 'Put on their best gear', detail: () => note('Best gear', 'Strict upgrades from their own bags first, then from yours. What you hand over becomes theirs.'), act: () => { const r = g.equipCompanionBest(id); a.result(r, r.equipped ? `${comp.name} puts on ${plural(r.equipped, 'piece')}.` : `${comp.name} already wears the best they can.`); } });
|
|
349
|
+
rows.push({ text: 'Give them something from your bags…', detail: () => note('Give', 'Anything you hand over is theirs. A Plans or Pattern they can use is learned on the spot.'), act: () => giveDialog(a, id) });
|
|
340
350
|
rows.push(...teachRow(a, comp));
|
|
341
|
-
rows.push({ text: 'Action bars…', detail: () =>
|
|
351
|
+
rows.push({ text: 'Action bars…', detail: () => note('Action bars', 'Their kit: what they can fire when you take them over, and the bag items their autopilot preps with.'), act: () => a.push(barsDialog(a, (x) => companionBarsOwner(x, findComp(x, id)))) });
|
|
342
352
|
rows.push(...settingsRows(a, comp));
|
|
343
353
|
rows.push(...petRows(a, comp));
|
|
344
354
|
rows.push(...questRows(a, comp));
|
|
@@ -382,7 +392,7 @@ export function companionDialog(app, id) {
|
|
|
382
392
|
rows.push(...bagRows(a, comp, bagCapacity(companionCharacter(comp, lvl, ds), ds)));
|
|
383
393
|
rows.push(...bankRows(a, comp));
|
|
384
394
|
|
|
385
|
-
rows.push(H(''), { text: red(`Release ${comp.name} for good`), detail: () =>
|
|
395
|
+
rows.push(H(''), { text: red(`Release ${comp.name} for good`), detail: () => note('Release', 'They leave your roster for good. You get the hire price back. Their bags and gear go with them.'),
|
|
386
396
|
act: () => a.confirm(`Release ${comp.name} for good? Their bags and gear go with them.`, () => { const r = g.releaseCompanions({ ids: [id] }); if (a.result(r, `${comp.name} leaves.${r.refundCopper ? ` You get ${moneyText(r.refundCopper)} back.` : ''}`)) a.popTo('__none__'); }) });
|
|
387
397
|
return rows;
|
|
388
398
|
},
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// The character create screen: faction, race, class, gender, name and the five looks of the body on one form, the
|
|
2
|
+
// hero drawn in the starting gear beside it, and under the form a card of what they start with (the race
|
|
3
|
+
// and the class in a sentence, racials, pools and attributes at level 1, spells, gear). Left and Right
|
|
4
|
+
// change the row under the cursor; Enter on Race or Class opens a list with each choice drawn; Enter on
|
|
5
|
+
// Name types one; Enter on Create rolls the character. Esc goes back to the character list.
|
|
6
|
+
import { bold, dim, green, red, wrapLines, fit } from './text.mjs';
|
|
7
|
+
import { className, raceName } from '../domain/core/ids.ts';
|
|
8
|
+
import { newGameState, newGameStateFromWorldBot, pickRandomWorldBot } from '../engine/game.ts';
|
|
9
|
+
import { maxHealth, maxPower, knownUsableSpells } from '../domain/character/character.ts';
|
|
10
|
+
import { effectiveStats, powerType } from '../domain/character/characterStats.ts';
|
|
11
|
+
import { bodyOptions, clampAppearance, cycleAppearance, lowestSelectable, slotCount, APPEARANCE_SLOTS } from '../domain/character/appearance.ts';
|
|
12
|
+
import { equippedEntry } from '../domain/items/itemInstance.ts';
|
|
13
|
+
import { nativeDisplayId } from '../domain/character/displayForm.ts';
|
|
14
|
+
import { creatureArt } from './modelArt.mjs';
|
|
15
|
+
import { paperdollArt } from './paperdoll.mjs';
|
|
16
|
+
|
|
17
|
+
// A sentence on each race and class, in the voice of the client's create screen; the racials, the start
|
|
18
|
+
// zone and everything else on the card come from the data.
|
|
19
|
+
const RACE_BLURB = {
|
|
20
|
+
1: 'The people of Stormwind, adaptable and diplomatic: their spirit runs deeper, reputation comes sooner, and they see through stealth a little further.',
|
|
21
|
+
2: 'The Horde of Thrall, hardy and fierce. Blood Fury hardens their attacks, stuns slide off them more often, their axes bite deeper, and their pets strike harder.',
|
|
22
|
+
3: 'The stout folk of Ironforge. Stoneform shrugs off poison, disease and bleeding; they resist frost, shoot guns truer, and can find treasure in the earth.',
|
|
23
|
+
4: 'The Kaldorei of Teldrassil, ancient and watchful. They melt into Shadowmeld, are harder to hit, resist nature, and walk as wisps when they fall.',
|
|
24
|
+
5: 'The Forsaken of the Undercity, free of the Lich King. Will of the Forsaken breaks fear, charm and sleep; they eat the dead to heal, breathe underwater longer, and resist shadow.',
|
|
25
|
+
6: 'The Shu\'halo of Mulgore, peaceful until roused. War Stomp stuns everything around them, they stand with more health, resist nature, and gather herbs faster.',
|
|
26
|
+
7: 'The tinkers of Gnomeregan, guests of Ironforge. Escape Artist slips any snare, their minds hold more mana, they resist arcane, and they are born engineers.',
|
|
27
|
+
8: 'The Darkspear, who followed Thrall from the Echo Isles. Berserking quickens blows and spells, they regenerate even in a fight, slay beasts harder, and throw and shoot bows truer.',
|
|
28
|
+
};
|
|
29
|
+
const CLASS_BLURB = {
|
|
30
|
+
1: 'Steel and rage. Warriors wear the heaviest armour, hold the line behind a shield or swing a two-hander, and build rage by hitting and being hit.',
|
|
31
|
+
2: 'The Light\'s champion in plate: a healer, a protector and a slow but sure fighter, with blessings for the party and a bubble for when it goes wrong.',
|
|
32
|
+
3: 'A ranger with a bow or gun and a pet tamed from the wild. The pet holds the foe while the hunter shoots, and the map shows what they track.',
|
|
33
|
+
4: 'A stealthy fighter who builds combo points with blades and spends them on finishing moves. Poisons, lockpicking and picking pockets come with the trade.',
|
|
34
|
+
5: 'The great healer, in cloth. Holy mends and shields the party; Shadow drains and torments from Shadowform, trading the healing for the dark.',
|
|
35
|
+
6: 'The Horde\'s keeper of the elements, in mail: totems, lightning and shocks, heals and weapon imbues, half melee and half spell at every turn.',
|
|
36
|
+
7: 'The master of frost, fire and arcane, fragile in cloth and lethal at range. Conjures food and water, sheeps a foe, and opens portals between cities.',
|
|
37
|
+
8: 'A wielder of fel magic in cloth: curses and damage over time, a demon summoned to tank or drain, and health, soul shards and healthstones traded for power.',
|
|
38
|
+
9: 'The shapeshifter: a bear to tank, a cat to claw, healing and nature magic in caster form, and travel form for the road.',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const NAME_RE = /^[A-Za-z]{2,12}$/;
|
|
42
|
+
const properName = (s) => s[0].toUpperCase() + s.slice(1).toLowerCase();
|
|
43
|
+
const rand = (n) => Math.floor(Math.random() * n);
|
|
44
|
+
const newSeed = () => (Date.now() ^ (Math.random() * 0x7fffffff)) >>> 0;
|
|
45
|
+
|
|
46
|
+
/** A random look within the body's legal choices (the create screen's shuffle; bald and hornless allowed where offered). */
|
|
47
|
+
export function randomLook(race, gender) {
|
|
48
|
+
const look = {};
|
|
49
|
+
APPEARANCE_SLOTS.forEach((key, s) => {
|
|
50
|
+
const floor = lowestSelectable(race, gender, s), count = slotCount(race, gender, s);
|
|
51
|
+
look[key] = count > floor ? floor + rand(count - floor) : 0;
|
|
52
|
+
});
|
|
53
|
+
return clampAppearance(look, race, gender);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A name from the world bots' pool for the gender, fit for a character. */
|
|
57
|
+
export function randomName(ds, gender) {
|
|
58
|
+
const pool = ds.botNames()[gender === 1 ? 'female' : 'male'];
|
|
59
|
+
for (let i = 0; i < 50 && pool.length; i++) { const n = pool[rand(pool.length)]; if (NAME_RE.test(n)) return properName(n); }
|
|
60
|
+
return gender === 1 ? 'Aalina' : 'Aalart';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The screen. `done(state)` gets the new character's game state; `onCancel` is Esc (back to the character list). */
|
|
64
|
+
export function characterCreation(app, done, { onCancel } = {}) {
|
|
65
|
+
const ds = app.ds;
|
|
66
|
+
const races = ds.races();
|
|
67
|
+
const classesOf = (race) => race.classes.filter((c) => ds.createInfo(race.id, c));
|
|
68
|
+
const st = { race: races[0], cls: classesOf(races[0])[0], gender: 0, name: '', look: null };
|
|
69
|
+
st.look = randomLook(st.race.id, st.gender);
|
|
70
|
+
// The state a Create would make for a body and class, less the name and the look: its character carries the
|
|
71
|
+
// starting gear, pools and attributes the card and the drawing read. One per combination, kept.
|
|
72
|
+
const previews = new Map();
|
|
73
|
+
const previewOf = (race, cls, gender) => {
|
|
74
|
+
const key = `${race}-${cls}-${gender}`;
|
|
75
|
+
let p = previews.get(key);
|
|
76
|
+
if (!p) { p = newGameState(ds, race, cls, 'Preview', 1, gender); previews.set(key, p); }
|
|
77
|
+
return p;
|
|
78
|
+
};
|
|
79
|
+
const preview = () => previewOf(st.race.id, st.cls, st.gender);
|
|
80
|
+
const hero = (cls = st.cls) => ({ ...previewOf(st.race.id, cls, st.gender).char, name: st.name || 'Preview', appearance: st.look });
|
|
81
|
+
const setRace = (r) => { st.race = r; lastRace[r.faction] = r; const cs = classesOf(r); if (!cs.includes(st.cls)) st.cls = cs[0]; st.look = clampAppearance(st.look, r.id, st.gender); };
|
|
82
|
+
// Faction comes first: Race cycles and lists only its races, and flipping back returns to the race last chosen there.
|
|
83
|
+
const factions = [...new Set(races.map((r) => r.faction))];
|
|
84
|
+
const racesOf = (f) => races.filter((r) => r.faction === f);
|
|
85
|
+
const lastRace = { [st.race.faction]: st.race };
|
|
86
|
+
const setFaction = (f) => setRace(lastRace[f] ?? racesOf(f)[0]);
|
|
87
|
+
const setGender = (g) => { st.gender = g; st.look = clampAppearance(st.look, st.race.id, g); };
|
|
88
|
+
const cycle = (list, cur, dir) => list[(list.indexOf(cur) + dir + list.length) % list.length];
|
|
89
|
+
const genderName = () => (st.gender === 1 ? 'Female' : 'Male');
|
|
90
|
+
const factionName = (r) => (r.faction === 'horde' ? 'Horde' : 'Alliance');
|
|
91
|
+
|
|
92
|
+
const view = { id: 'create', title: 'New character', hint: 'Left Right: change Enter: pick or type' + (onCancel ? ' Esc: character list' : '') };
|
|
93
|
+
const rowAt = (id) => app.rowsOf(view).findIndex((r) => r.id === id);
|
|
94
|
+
const goTo = (id) => { const i = rowAt(id); if (i >= 0) { view.cursor = i; app.ensureVisible(view, app.rowsOf(view)); } app.render(); };
|
|
95
|
+
|
|
96
|
+
// The hero drawn for the pane: the body in its starting gear with the look, or the bare body when the gear pack lacks it.
|
|
97
|
+
const drawn = (cls, maxCols, maxRows) => paperdollArt(hero(cls), maxCols, maxRows) ?? creatureArt(nativeDisplayId(st.race.id, st.gender), maxCols, maxRows);
|
|
98
|
+
const withBody = (text, art) => (art ? [...text, '', ...art] : text);
|
|
99
|
+
|
|
100
|
+
const askName = () => app.prompt('Name (2-12 letters): ', (raw) => {
|
|
101
|
+
const name = raw.trim();
|
|
102
|
+
if (!NAME_RE.test(name)) { app.say(red('Letters only, 2 to 12 of them.')); askName(); return; } // said in the log: the prompt covers the hint line
|
|
103
|
+
st.name = properName(name);
|
|
104
|
+
goTo('create'); // named: Enter again creates
|
|
105
|
+
}); // the line starts empty: Esc keeps the name they had
|
|
106
|
+
const rollName = () => { st.name = randomName(ds, st.gender); app.render(); };
|
|
107
|
+
const shuffle = () => { st.look = randomLook(st.race.id, st.gender); app.render(); };
|
|
108
|
+
const create = () => {
|
|
109
|
+
if (!st.name) { app.flashMsg('Give them a name first.'); goTo('name'); return; }
|
|
110
|
+
done(newGameState(ds, st.race.id, st.cls, st.name, newSeed(), st.gender, st.look));
|
|
111
|
+
};
|
|
112
|
+
const randomCharacter = () => {
|
|
113
|
+
const seed = newSeed();
|
|
114
|
+
done(newGameStateFromWorldBot(ds, pickRandomWorldBot(ds, seed), seed));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Enter on Race: the faction's races listed with each body drawn; on Class: every class this race may be, drawn in its gear.
|
|
118
|
+
const racePicker = () => {
|
|
119
|
+
const list = racesOf(st.race.faction);
|
|
120
|
+
const p = app.pick('Race', list.map((r) => ({
|
|
121
|
+
label: `${fit(r.name, 10)} ${dim(factionName(r))}`,
|
|
122
|
+
detail: () => {
|
|
123
|
+
const L = app.layout();
|
|
124
|
+
const text = [bold(r.name), dim(factionName(r)), `Starts in ${ds.zone(r.startZone)?.name ?? r.startZone}`, dim('Classes: ') + classesOf(r).map(className).join(', ')];
|
|
125
|
+
return withBody(text, creatureArt(nativeDisplayId(r.id, st.gender), L.detailW - 2, L.mainH - text.length - 1));
|
|
126
|
+
},
|
|
127
|
+
value: r,
|
|
128
|
+
})), (r) => { setRace(r); app.render(); }, { hint: 'Enter: choose' });
|
|
129
|
+
p.cursor = list.indexOf(st.race);
|
|
130
|
+
};
|
|
131
|
+
const classPicker = () => {
|
|
132
|
+
const classes = classesOf(st.race);
|
|
133
|
+
const p = app.pick('Class', classes.map((c) => ({
|
|
134
|
+
label: className(c),
|
|
135
|
+
detail: () => {
|
|
136
|
+
const L = app.layout();
|
|
137
|
+
const text = [bold(className(c)), ...wrapLines(dim(CLASS_BLURB[c] ?? ''), L.detailW - 2)];
|
|
138
|
+
return withBody(text, drawn(c, L.detailW - 2, L.mainH - text.length - 1));
|
|
139
|
+
},
|
|
140
|
+
value: c,
|
|
141
|
+
})), (c) => { st.cls = c; app.render(); }, { hint: 'Enter: choose' });
|
|
142
|
+
p.cursor = classes.indexOf(st.cls);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// A form row: the label, the value, and the arrows when Left and Right change it.
|
|
146
|
+
const field = (id, label, value, { cycle: cyc, act, arrows = true }) => ({ id, text: `${fit(label, 12)} ${fit(bold(value), 10)}${arrows ? ` ${dim('◂ ▸')}` : ''}`, cycle: cyc, act });
|
|
147
|
+
const H = (text = '', plain = false) => ({ text, header: true, plain });
|
|
148
|
+
|
|
149
|
+
// Under the form: what the race and the class are, and what a level 1 of them starts with.
|
|
150
|
+
const card = () => {
|
|
151
|
+
const L = app.layout();
|
|
152
|
+
const w = Math.max(24, (L.detailW ? L.listW : L.W) - 3);
|
|
153
|
+
const wrap = (t) => wrapLines(t, w).map((l) => H(l, true));
|
|
154
|
+
const r = st.race, p = preview(), ch = p.char;
|
|
155
|
+
const out = [H(`${r.name} · ${factionName(r)}`)];
|
|
156
|
+
if (RACE_BLURB[r.id]) out.push(...wrap(RACE_BLURB[r.id]));
|
|
157
|
+
out.push(...wrap(dim(`Starts in ${ds.zone(r.startZone)?.name ?? r.startZone}; home city ${r.city}.`)));
|
|
158
|
+
const racials = [...new Set(r.racials.map((id) => ds.spell(id)?.name).filter(Boolean))];
|
|
159
|
+
if (racials.length) out.push(...wrap(`${dim('Racials:')} ${racials.join(', ')}`));
|
|
160
|
+
out.push(H(), H(className(st.cls)));
|
|
161
|
+
if (CLASS_BLURB[st.cls]) out.push(...wrap(CLASS_BLURB[st.cls]));
|
|
162
|
+
const s = effectiveStats(ch, ds), pt = powerType(ch);
|
|
163
|
+
out.push(...wrap(`${dim('Level 1:')} ${maxHealth(ch, ds)} health, ${maxPower(ch, ds)} ${pt}`));
|
|
164
|
+
out.push(...wrap(`${dim('Str')} ${s.str} ${dim('Agi')} ${s.agi} ${dim('Sta')} ${s.sta} ${dim('Int')} ${s.int} ${dim('Spi')} ${s.spi}`));
|
|
165
|
+
const spells = [...new Set(knownUsableSpells(ch, p.spellbook, ds).map((sp) => sp.name))];
|
|
166
|
+
if (spells.length) out.push(...wrap(`${dim('Knows:')} ${spells.join(', ')}`));
|
|
167
|
+
const worn = new Set(Object.values(ch.equipped ?? {}).map(equippedEntry));
|
|
168
|
+
const wears = [], carries = [];
|
|
169
|
+
for (const it of ds.createInfo(r.id, st.cls)?.items ?? []) {
|
|
170
|
+
const def = ds.item(it.item);
|
|
171
|
+
if (def) (worn.has(it.item) ? wears : carries).push(it.amount > 1 ? `${def.name} ×${it.amount}` : def.name);
|
|
172
|
+
}
|
|
173
|
+
if (wears.length) out.push(...wrap(`${dim('Wears:')} ${wears.join(', ')}`));
|
|
174
|
+
if (carries.length) out.push(...wrap(`${dim('Carries:')} ${carries.join(', ')}`));
|
|
175
|
+
return out;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
view.rows = () => {
|
|
179
|
+
const rows = [
|
|
180
|
+
field('faction', 'Faction', factionName(st.race), { cycle: (d) => setFaction(cycle(factions, st.race.faction, d)), act: () => { setFaction(cycle(factions, st.race.faction, 1)); app.render(); } }),
|
|
181
|
+
field('race', 'Race', st.race.name, { cycle: (d) => setRace(cycle(racesOf(st.race.faction), st.race, d)), act: racePicker }),
|
|
182
|
+
field('class', 'Class', className(st.cls), { cycle: (d) => { st.cls = cycle(classesOf(st.race), st.cls, d); }, act: classPicker }),
|
|
183
|
+
field('gender', 'Gender', genderName(), { cycle: () => setGender(st.gender === 1 ? 0 : 1), act: () => { setGender(st.gender === 1 ? 0 : 1); app.render(); } }),
|
|
184
|
+
{ id: 'name', text: `${fit('Name', 12)} ${st.name ? bold(st.name) : dim('Enter: type one n: a random one')}`, act: askName },
|
|
185
|
+
H(), H('Look'),
|
|
186
|
+
];
|
|
187
|
+
// The body's own slots, by the names the client gives them (Horn Style, Tusks…); a slot with one choice is left out.
|
|
188
|
+
const body = bodyOptions(st.race.id, st.gender);
|
|
189
|
+
APPEARANCE_SLOTS.forEach((key, s) => {
|
|
190
|
+
const opt = body?.options[s];
|
|
191
|
+
if (!opt) return;
|
|
192
|
+
const floor = lowestSelectable(st.race.id, st.gender, s), count = opt.count;
|
|
193
|
+
if (count - floor <= 1) return;
|
|
194
|
+
const i = st.look[key];
|
|
195
|
+
const value = i === 0 && opt.none ? 'None' : `${i + 1 - floor} of ${count - floor}`;
|
|
196
|
+
rows.push(field(key, opt.name, value, { cycle: (d) => { st.look = cycleAppearance(st.look, st.race.id, st.gender, s, d); }, act: () => { st.look = cycleAppearance(st.look, st.race.id, st.gender, s, 1); app.render(); } }));
|
|
197
|
+
});
|
|
198
|
+
rows.push({ id: 'shuffle', text: `${fit('', 12)} ${dim('r: a random look')}`, act: shuffle });
|
|
199
|
+
rows.push(H());
|
|
200
|
+
rows.push({ id: 'create', text: green(bold(st.name ? `Create ${st.name}, ${genderName()} ${st.race.name} ${className(st.cls)}` : 'Create (needs a name)')), act: create });
|
|
201
|
+
rows.push({ id: 'random', text: `Random character ${dim('a world bot at its own level, geared and named')}`, act: randomCharacter });
|
|
202
|
+
rows.push(H(), ...card());
|
|
203
|
+
return rows;
|
|
204
|
+
};
|
|
205
|
+
// The pane: the hero as they will stand on the Character tab, in the starting gear, as large as the pane allows.
|
|
206
|
+
view.detail = () => {
|
|
207
|
+
const L = app.layout();
|
|
208
|
+
const head = [`${st.name ? bold(st.name) : dim('Unnamed')} ${dim(`${genderName()} ${raceName(st.race.id)} ${className(st.cls)}`)}`, ''];
|
|
209
|
+
const art = drawn(st.cls, L.detailW - 2, L.mainH - head.length);
|
|
210
|
+
return art ? [...head, ...art] : head;
|
|
211
|
+
};
|
|
212
|
+
view.keys = {
|
|
213
|
+
n: { hint: 'random name', fn: rollName },
|
|
214
|
+
r: { hint: 'random look', fn: shuffle },
|
|
215
|
+
};
|
|
216
|
+
view.onKey = (a, k, row) => {
|
|
217
|
+
if ((k === 'left' || k === 'right') && row?.cycle) { row.cycle(k === 'left' ? -1 : 1); a.render(); return true; }
|
|
218
|
+
if (k === 'escape') { onCancel?.(); return true; }
|
|
219
|
+
return false;
|
|
220
|
+
};
|
|
221
|
+
app.push(view);
|
|
222
|
+
return view;
|
|
223
|
+
}
|
package/src/cli/tuiDungeon.mjs
CHANGED
|
@@ -314,7 +314,7 @@ function objectRow(app, def, o) {
|
|
|
314
314
|
const name = spawn.name ?? ds.creature(spawn.entry)?.name ?? `npc ${spawn.entry}`;
|
|
315
315
|
const marks = `${quests.turnIn.length ? green(' ?') : ''}${quests.offer.length ? yellow(' !') : ''}${quests.escort.length ? cyan(' escort') : ''}`;
|
|
316
316
|
return {
|
|
317
|
-
text: `${dim('
|
|
317
|
+
text: `${dim('•')} ${name}${marks}`,
|
|
318
318
|
detail: () => [bold(name), ...quests.offer.map((q) => `${yellow('!')} ${q.title}`), ...quests.turnIn.map((q) => `${green('?')} ${q.title}`), ...quests.escort.map((q) => `${cyan('escort')} ${q.title}`), '', dim('Enter: speak with them')],
|
|
319
319
|
act: () => npcDialog(app, spawn.entry, name),
|
|
320
320
|
};
|