azeroth-shell 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -10
- package/bin/azsh.mjs +15 -1
- package/package.json +5 -2
- package/src/cli/hdArt.mjs +1 -1
- package/src/cli/iconArt.mjs +11 -1
- package/src/cli/modelArt.mjs +7 -4
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/repl.mjs +16 -0
- package/src/cli/sixel.mjs +26 -14
- package/src/cli/text.mjs +16 -0
- package/src/cli/tui.mjs +33 -9
- package/src/cli/tuiAutopilot.mjs +17 -8
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiCheck.mjs +276 -15
- package/src/cli/tuiCity.mjs +31 -31
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +189 -29
- package/src/cli/tuiCreate.mjs +7 -0
- package/src/cli/tuiDungeon.mjs +56 -20
- package/src/cli/tuiFight.mjs +63 -21
- package/src/cli/tuiFightPanes.mjs +15 -6
- package/src/cli/tuiFloor.mjs +20 -8
- package/src/cli/tuiGear.mjs +9 -10
- package/src/cli/tuiLoot.mjs +5 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +6 -5
- package/src/cli/tuiPartyStage.mjs +36 -7
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +23 -23
- package/src/cli/tuiSaves.mjs +14 -0
- package/src/cli/tuiSearch.mjs +4 -5
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +72 -16
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +371 -84
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +88 -5
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +36 -36
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- package/src/domain/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/engine/combat/pickAction.ts +3494 -3487
- package/src/engine/combat/sessionChase.ts +12 -1
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/types.ts +4 -0
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ front end. No graphics, no browser, no build step.
|
|
|
13
13
|
- [Install](#install)
|
|
14
14
|
- [Keys](#keys)
|
|
15
15
|
- [Playing](#playing): [character](#new-character) · [world and autopilot](#world-and-the-autopilot) ·
|
|
16
|
-
[fights](#fights) · [rotation](#rotation) · [gear](#
|
|
16
|
+
[fights](#fights) · [rotation](#rotation) · [gear](#character-and-bags) · [vendors, trainers, talents](#vendors-trainers-talents) ·
|
|
17
17
|
[professions](#professions) · [companions](#companions) · [dungeons](#dungeons) · [city services](#city-services)
|
|
18
18
|
- [Art and terminals](#art-and-terminals)
|
|
19
19
|
- [Line mode and saves](#line-mode-and-saves)
|
|
@@ -23,11 +23,19 @@ front end. No graphics, no browser, no build step.
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
npx azeroth-shell
|
|
26
|
+
npx azeroth-shell # run it straight from npm
|
|
27
27
|
npm install -g azeroth-shell # or install it once, then:
|
|
28
|
-
azeroth-shell
|
|
28
|
+
azeroth-shell # or just azsh
|
|
29
|
+
azeroth-shell --art # optional: fetch the full-size art (179 MB) now instead of when asked
|
|
29
30
|
```
|
|
30
31
|
|
|
32
|
+
The package is the whole game. The one extra download is the full-size art, which only terminals
|
|
33
|
+
that draw sixel images use. It never comes down on its own: on such a terminal azsh asks first,
|
|
34
|
+
showing the size and the folder, or you fetch it with `--art` (that also works through
|
|
35
|
+
`npx azeroth-shell --art`). It comes from this repo's `art-1` GitHub release, and every file is
|
|
36
|
+
checked against the sha256 in `src/data/hd-art.json` before it is used. You don't need to know
|
|
37
|
+
beforehand whether your terminal draws sixel, because azsh checks that itself.
|
|
38
|
+
|
|
31
39
|
From a checkout: `npm install -g .` or `node bin/azsh.mjs`.
|
|
32
40
|
|
|
33
41
|
Needs Node 22.18 or newer (it strips TypeScript types natively). No dependencies. The tree is
|
|
@@ -42,7 +50,7 @@ every screen redraws in place.
|
|
|
42
50
|
|
|
43
51
|
| Key | Does |
|
|
44
52
|
| --- | --- |
|
|
45
|
-
| `1`-`9` `0`, `Tab`, `Left` `Right` | World, Quests, Bags,
|
|
53
|
+
| `1`-`9` `0`, `Tab`, `Left` `Right` | World, Quests, Bags, Character, Spells, Rotation, Talents, Travel, Professions, Companions; Settings is the last tab (`Tab`/`Left`) |
|
|
46
54
|
| `Up` `Down` `j` `k` | move the cursor; the right pane describes the row |
|
|
47
55
|
| `Enter` | act on the row: fight, open a quest, talk to a vendor or trainer, equip, cast, travel |
|
|
48
56
|
| `Esc` | close the window on top; on a tab, back to World |
|
|
@@ -156,21 +164,24 @@ conditions hold fires.
|
|
|
156
164
|
| `D` | target dummy: run the rotation against a dummy |
|
|
157
165
|
| `A` | autopilot settings |
|
|
158
166
|
|
|
159
|
-
###
|
|
167
|
+
### Character and Bags
|
|
160
168
|
|
|
161
|
-
**
|
|
169
|
+
**Character** shows every slot with what you wear, and your health, power, experience, money,
|
|
170
|
+
attributes and combat ratings under the item tooltip; `s` opens the rest (weapon skills,
|
|
171
|
+
professions, auras, home, pet, honor, reputation). `Enter` on a slot lists everything in your bags (and
|
|
162
172
|
in the bank, in a bank town) that fits it, with the stat, armor and damage difference in the detail
|
|
163
173
|
pane; `Enter` there puts it on. The engine's equipment helper scores your bags against what you
|
|
164
174
|
wear: upgrades are listed at the top of the tab and marked `▲` in Bags. From 160 columns the tab is
|
|
165
175
|
a character sheet: you in your gear in the middle, the worn pieces' icons down both sides, Shift+arrows
|
|
166
176
|
walking the slots.
|
|
167
177
|
|
|
168
|
-

|
|
169
179
|
|
|
170
180
|
| Key | Does |
|
|
171
181
|
| --- | --- |
|
|
172
182
|
| `Enter` on a slot | list what fits, then `Enter` to equip |
|
|
173
|
-
| `
|
|
183
|
+
| `s` | your stats, skills, auras, pet, honor and reputation |
|
|
184
|
+
| `u` (Character or Bags) | put on every upgrade at once |
|
|
174
185
|
| `x` | take a piece off |
|
|
175
186
|
| `b` (Bags) | open the bank, in a bank town |
|
|
176
187
|
| `g` (Bags) | open Send goods (see [Professions](#professions)) |
|
|
@@ -294,21 +305,41 @@ terminal's own retro effect (Windows Terminal: `"experimental.retroTerminalEffec
|
|
|
294
305
|
profile; cool-retro-term elsewhere).
|
|
295
306
|
|
|
296
307
|
At 90 columns or wider the creature you face is drawn beside the fight frames and under its detail
|
|
297
|
-
on the World tab. Your own character, in the gear you wear, stands on the Character tab, on the
|
|
308
|
+
on the World tab. Your own character, in the gear you wear, stands on the Character tab and its stats page, on the
|
|
298
309
|
character picker, at creation and (from 130 columns) on the other side of the fight, and a
|
|
299
310
|
companion's page shows them the same way. It is truecolour half-block art baked from the game's
|
|
300
311
|
models; a terminal without 24-bit colour shows it in the nearest colours it has.
|
|
301
312
|
|
|
302
313
|
A terminal that draws sixel images (Windows Terminal 1.22+, WezTerm, foot, iTerm2; VS Code with
|
|
303
|
-
`terminal.integrated.enableImages`)
|
|
314
|
+
`terminal.integrated.enableImages`) can draw every creature, every character in their gear and every
|
|
304
315
|
icon in real pixels instead. azsh asks the terminal at start.
|
|
305
316
|
|
|
317
|
+
The Travel tab has the world map under whatever you point at: both continents, a gold pin where you
|
|
318
|
+
stand, a cyan one on the zone under the cursor and the planned route dotted between them, legs by
|
|
319
|
+
sea and air included; on the road the pin walks its leg. It is the same terrain map the browser game
|
|
320
|
+
pans around, as half-block cells or, on a sixel terminal, as pixels. Both of its packs are small enough
|
|
321
|
+
to ship, so the map needs no download. The pane is short on a short terminal, and so is the map:
|
|
322
|
+
give the window forty rows or more and it grows into them.
|
|
323
|
+
|
|
324
|
+
The full-size art for that is not in the npm package: it is about 179 MB, too much to ship to every
|
|
325
|
+
terminal that cannot draw it. When azsh finds a sixel terminal without it, it offers to download it
|
|
326
|
+
from the project's GitHub release into `~/.azsh/art/`. Each file is checked against a sha256 the
|
|
327
|
+
package carries. Answer "Not now" and it asks again next run; "Never ask" stops the prompt. `azsh --art` fetches it
|
|
328
|
+
from the shell without starting the game. Until the
|
|
329
|
+
art is there, the half-block art stays.
|
|
330
|
+
|
|
331
|
+
| Command | Does |
|
|
332
|
+
| --- | --- |
|
|
333
|
+
| `:art` | say whether this terminal draws sixel and whether the full-size art is here |
|
|
334
|
+
| `:art download` | fetch the full-size art now (progress on the hint row, the screen redraws when done) |
|
|
335
|
+
|
|
306
336
|

|
|
307
337
|
|
|
308
338
|
| Variable | Does |
|
|
309
339
|
| --- | --- |
|
|
310
340
|
| `AZSH_SIXEL=0` | keep the half-block art |
|
|
311
341
|
| `AZSH_SIXEL=1` | turn sixel on for a terminal that does not say |
|
|
342
|
+
| `AZSH_ART_DIR` | read the full-size art from (and download it to) this folder instead |
|
|
312
343
|
| `AZSH_HOME` | move the save folder (default `~/.azsh`) |
|
|
313
344
|
|
|
314
345
|
## Line mode and saves
|
|
@@ -368,6 +399,8 @@ npm run data:pack # gzip the bake after a data refresh; data:unpack reverses
|
|
|
368
399
|
npm run art:bake # rebuild src/data/model-art.bin.gz from phonecraft's creature renders (needs ffmpeg)
|
|
369
400
|
npm run art:gear # rebuild src/data/gear-art.bin.gz from phonecraft's paperdoll bake (needs Python with Pillow)
|
|
370
401
|
npm run art:icons # rebuild src/data/icon-art.bin.gz from phonecraft's spell and item icons (needs ffmpeg)
|
|
402
|
+
npm run art:map # rebuild the Travel tab's world map, src/data/world-map.bin.gz and world-map-sixel.bin.br,
|
|
403
|
+
# from phonecraft's combined Azeroth terrain map (needs ffmpeg)
|
|
371
404
|
npm run art:hd # rebuild the full-size art sixel draws: model-art-hd.bin.br, icon-art-hd.bin.br and
|
|
372
405
|
# gear-art-hd/<body>.bin.br (needs ffmpeg, and Python with Pillow for the gear)
|
|
373
406
|
```
|
package/bin/azsh.mjs
CHANGED
|
@@ -6,8 +6,22 @@ if (major < 22 || (major === 22 && minor < 18)) {
|
|
|
6
6
|
console.error(`azsh needs Node 22.18 or newer (found ${process.versions.node}).`);
|
|
7
7
|
process.exit(1);
|
|
8
8
|
}
|
|
9
|
-
await import('../src/cli/register.mjs');
|
|
10
9
|
const argv = process.argv.slice(2);
|
|
10
|
+
if (argv.includes('--art')) {
|
|
11
|
+
// Fetch the full-size art up front instead of waiting for the in-game offer; says what and from where first.
|
|
12
|
+
const { hdManifest, missingHdBytes, downloadHdArt, downloadDir } = await import('../src/cli/hdArt.mjs');
|
|
13
|
+
const m = hdManifest(), bytes = missingHdBytes();
|
|
14
|
+
if (!m) { console.error('This build has no art manifest.'); process.exit(1); }
|
|
15
|
+
if (!bytes) { console.log(`The full-size art is all here (${downloadDir()}).`); process.exit(0); }
|
|
16
|
+
console.log(`Fetching ${Math.round(bytes / 1e6)} MB of full-size art (${m.files.length} files, sha256-checked)\n from ${m.base}\n into ${downloadDir()}`);
|
|
17
|
+
const res = await downloadHdArt((done, total) => {
|
|
18
|
+
if (process.stdout.isTTY) process.stdout.write(`\r ${Math.round(done / 1e6)} of ${Math.round(total / 1e6)} MB`);
|
|
19
|
+
});
|
|
20
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
21
|
+
console.log(res.ok ? 'Done. A sixel terminal now draws creatures, characters and icons in real pixels.' : `Stopped: ${res.reason}`);
|
|
22
|
+
process.exit(res.ok ? 0 : 1);
|
|
23
|
+
}
|
|
24
|
+
await import('../src/cli/register.mjs');
|
|
11
25
|
const wantRepl = argv.includes('--repl') || (!argv.includes('--tui') && (!process.stdin.isTTY || !process.stdout.isTTY));
|
|
12
26
|
if (wantRepl) {
|
|
13
27
|
const { main } = await import('../src/cli/repl.mjs');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "azeroth-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Azeroth shell. A WoW Classic 1.12 text RPG for the terminal, built on CMaNGOS data.",
|
|
6
6
|
"keywords": [
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
"src/",
|
|
31
31
|
"!src/data/*-hd.bin.br",
|
|
32
32
|
"!src/data/gear-art-hd/",
|
|
33
|
+
"!src/data/*.only.bin.br",
|
|
34
|
+
"!src/data/gear-art-hd.only/",
|
|
33
35
|
"docs/"
|
|
34
36
|
],
|
|
35
37
|
"engines": {
|
|
@@ -41,9 +43,10 @@
|
|
|
41
43
|
"check": "node --import ./src/cli/register.mjs src/cli/bootCheck.mjs",
|
|
42
44
|
"data:pack": "node scripts/pack-data.mjs",
|
|
43
45
|
"data:unpack": "node scripts/pack-data.mjs --unpack",
|
|
44
|
-
"art:bake": "node scripts/bake-model-art.mjs",
|
|
46
|
+
"art:bake": "node scripts/bake-model-art.mjs && node scripts/bake-model-art.mjs --worn",
|
|
45
47
|
"art:gear": "python scripts/bake-gear-art.py",
|
|
46
48
|
"art:icons": "node scripts/bake-icon-art.mjs",
|
|
49
|
+
"art:map": "node scripts/bake-world-map.mjs && node scripts/bake-world-map.mjs --sixel",
|
|
47
50
|
"art:hd": "node scripts/bake-model-art.mjs --hd && node scripts/bake-icon-art.mjs --hd && python scripts/bake-gear-art.py --hd",
|
|
48
51
|
"art:publish": "node scripts/publish-hd-art.mjs",
|
|
49
52
|
"build:exe": "node scripts/build-exe.mjs",
|
package/src/cli/hdArt.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The full-size art sixel draws (sixel.mjs, modelArt.mjs): creature sprites, icons and one gear file per body,
|
|
2
|
-
// about
|
|
2
|
+
// about 179 MB, too much to ship to every terminal that cannot draw it. The package carries only the manifest
|
|
3
3
|
// (src/data/hd-art.json: the release tag, and each file's name, size and sha256); the files are assets of that
|
|
4
4
|
// GitHub release, fetched on request into ~/.azsh/art/<tag>/ and checked against the manifest.
|
|
5
5
|
//
|
package/src/cli/iconArt.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { readFileSync } from 'node:fs';
|
|
9
9
|
import { gunzipSync } from 'node:zlib';
|
|
10
10
|
import { useColor, vis, wrapLines } from './text.mjs';
|
|
11
|
-
import { sixelIconCells } from './sixel.mjs';
|
|
11
|
+
import { sixelIconCells, sixelOn } from './sixel.mjs';
|
|
12
12
|
|
|
13
13
|
const PACK_URL = new URL('../data/icon-art.bin.gz', import.meta.url);
|
|
14
14
|
const SIZES = [8, 6, 4];
|
|
@@ -94,6 +94,16 @@ export function iconArt(name, cols = 4, tone = '') {
|
|
|
94
94
|
return iconCells(name, cols, tone)?.map((row) => row.join('')) ?? null;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* A one-line icon two cells wide and a space, to lead a line of text in a list (what someone wears, a
|
|
99
|
+
* quest's rewards). Only where the terminal draws sixel: as half-block cells it would be a smudge of colour,
|
|
100
|
+
* so there it is ''. A name with no art keeps the column with blanks.
|
|
101
|
+
*/
|
|
102
|
+
export function miniIcon(name) {
|
|
103
|
+
if (!sixelOn()) return '';
|
|
104
|
+
return `${iconArt(name, 2)?.[0] ?? ' '} `;
|
|
105
|
+
}
|
|
106
|
+
|
|
97
107
|
/** A blank the size of an icon, for rows in a list that mixes icons and none. */
|
|
98
108
|
export const iconBlank = (cols = 4) => Array.from({ length: cols / 2 }, () => ' '.repeat(cols));
|
|
99
109
|
|
package/src/cli/modelArt.mjs
CHANGED
|
@@ -175,18 +175,21 @@ const gearHd = new Map(); // slug -> { buf, pal, canvas, skins, overlays } or nu
|
|
|
175
175
|
export function forgetHdArt() { forgetSixelPacks(); gearHd.clear(); cache.clear(); }
|
|
176
176
|
|
|
177
177
|
/** A body's hd file (scripts/bake-gear-art.py --hd): its palette, then the body section the small pack has
|
|
178
|
-
* for it.
|
|
178
|
+
* for it. Version 1 (a release's 128 px bodies) holds the canvas and each x y w h in a byte, version 2 (256 px,
|
|
179
|
+
* past a byte) in a u16. Read the first time the body is drawn. */
|
|
179
180
|
function gearBodyHd(slug) {
|
|
180
181
|
if (gearHd.has(slug)) return gearHd.get(slug);
|
|
181
182
|
let body = null;
|
|
182
183
|
try {
|
|
183
184
|
const buf = brotliDecompressSync(readFileSync(hdArtPath(`gear-art-hd/${slug}.bin.br`)));
|
|
184
|
-
|
|
185
|
+
const version = buf[7];
|
|
186
|
+
if (buf.toString('ascii', 0, 7) === 'AZGEARB' && (version === 1 || version === 2)) {
|
|
185
187
|
let off = 8;
|
|
188
|
+
const n = version === 2 ? 2 : 1, num = (o) => (n === 2 ? buf.readUInt16LE(o) : buf[o]);
|
|
186
189
|
const palLen = buf[off++];
|
|
187
190
|
const pal = buf.subarray(off, off + palLen * 3); off += palLen * 3;
|
|
188
191
|
off += 1 + buf[off]; // the slug again
|
|
189
|
-
const canvas =
|
|
192
|
+
const canvas = num(off); off += n;
|
|
190
193
|
body = { buf, pal, canvas, skins: new Map(), overlays: new Map() };
|
|
191
194
|
const skinCount = buf[off++];
|
|
192
195
|
for (let i = 0; i < skinCount; i++) { body.skins.set(buf[off], off + 1); off += 1 + canvas * canvas; }
|
|
@@ -194,7 +197,7 @@ function gearBodyHd(slug) {
|
|
|
194
197
|
for (let i = 0; i < ovCount; i++) {
|
|
195
198
|
const keyLen = buf[off++];
|
|
196
199
|
const key = buf.toString('ascii', off, off + keyLen); off += keyLen;
|
|
197
|
-
const x =
|
|
200
|
+
const x = num(off), y = num(off + n), w = num(off + 2 * n), h = num(off + 3 * n); off += 4 * n;
|
|
198
201
|
body.overlays.set(key, { off, x, y, w, h });
|
|
199
202
|
off += w * h;
|
|
200
203
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Out-of-combat prep before a pull: the rotation's buffs phase (armours, Intellect, imbues, conjures, the pet)
|
|
2
|
+
// cast standing still, not on the fight's first ticks. phonecraft autopilotController.ts prepOoc (:889) without
|
|
3
|
+
// its fillMana: the mana the buffs cost stays spent, and the autopilot drinks it back before it pulls.
|
|
4
|
+
import { pickOocAction, useOocPrepItem } from '../autopilot/combatRotation.ts';
|
|
5
|
+
import { maxPower } from '../domain/character/character.ts';
|
|
6
|
+
import { powerType } from '../domain/character/characterStats.ts';
|
|
7
|
+
|
|
8
|
+
const MAX_PREP_ACTIONS = 8; // each pick is a guarded rule cast once; the cap keeps a refused rule from looping
|
|
9
|
+
|
|
10
|
+
/** Cast what the buffs phase wants up. Returns the names cast and the mana left as a fraction (1 for non-mana). */
|
|
11
|
+
export function prepOoc(g, ds) {
|
|
12
|
+
const cast = [];
|
|
13
|
+
for (let i = 0; i < MAX_PREP_ACTIONS; i++) {
|
|
14
|
+
const action = pickOocAction(g.rotation(), g.state, ds);
|
|
15
|
+
if (!action) break;
|
|
16
|
+
if (action.kind === 'spell') {
|
|
17
|
+
if (!g.castOutOfCombat(action.spellId).ok) break;
|
|
18
|
+
cast.push(ds.spell(action.spellId)?.name ?? `spell ${action.spellId}`);
|
|
19
|
+
} else if (action.kind === 'item') {
|
|
20
|
+
if (!useOocPrepItem(g, action)) break;
|
|
21
|
+
cast.push(ds.item(action.itemEntry)?.name ?? `item ${action.itemEntry}`);
|
|
22
|
+
} else break;
|
|
23
|
+
}
|
|
24
|
+
const ch = g.state.char;
|
|
25
|
+
const max = powerType(ch) === 'mana' ? maxPower(ch, ds) : 0;
|
|
26
|
+
return { cast: [...new Set(cast)], manaPct: max > 0 ? ch.power / max : 1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The log line for a prep that cast something, or null. */
|
|
30
|
+
export function prepText(prep) {
|
|
31
|
+
return prep.cast.length ? `Buffed up: ${prep.cast.join(', ')}` : null;
|
|
32
|
+
}
|
package/src/cli/repl.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { planStep, noteQuestDefeat } from '../autopilot/plan.ts';
|
|
|
17
17
|
|
|
18
18
|
// Deaths on one quest before world questing gives up on it and moves to the next.
|
|
19
19
|
const QUEST_SKIP_DEFEATS = 2;
|
|
20
|
+
import { prepOoc, prepText } from './oocPrep.mjs';
|
|
20
21
|
import { pullOpts, squadLogNames, squadModeOf, setSquadMode, SQUAD_MODE_LABEL, SQUAD_MODE_WORDS, COMPANION_HINT_LINES } from './squad.mjs';
|
|
21
22
|
|
|
22
23
|
const useColor = stdout.isTTY && !process.env.NO_COLOR;
|
|
@@ -264,6 +265,8 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
|
|
|
264
265
|
|
|
265
266
|
/** g.fight with the squad when the squad mode says so; the log names the companions. */
|
|
266
267
|
const quickFight = (entry) => {
|
|
268
|
+
const buffed = prepText(prepOoc(g, ds));
|
|
269
|
+
if (buffed) out(dim(` ${buffed}`));
|
|
267
270
|
const opts = pullOpts(g, ds, buffPace, entry, (l) => out(dim(` ${l}`)));
|
|
268
271
|
const r = g.fight(entry, opts);
|
|
269
272
|
return { r, logNames: opts ? squadLogNames(g, entry, names()) : names() };
|
|
@@ -603,6 +606,7 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
|
|
|
603
606
|
let done = false;
|
|
604
607
|
let lastKind = null;
|
|
605
608
|
let lastQuest = null; // the quest the latest step worked on, blamed when the planner gives up
|
|
609
|
+
let restedForBuffs = false; // the pull's buffs already sent the run to rest once
|
|
606
610
|
let stopReason = null; // why the run ended: the planner's stop step, or 'steps' when the step budget ran out
|
|
607
611
|
const skipped = [];
|
|
608
612
|
const inLog = (entry) => g.state.questlog.quests.some((qs) => qs.entry === entry && qs.status !== 'turnedIn');
|
|
@@ -653,6 +657,18 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
|
|
|
653
657
|
switch (step.kind) {
|
|
654
658
|
case 'fight': {
|
|
655
659
|
const c = ds.creature(step.creatureEntry);
|
|
660
|
+
// Buff before the pull, then drink back what the buffs cost: casting them on the fight's first ticks
|
|
661
|
+
// sent a mage in at three quarters of its bar. Once per pull, so a buff that falls off while it
|
|
662
|
+
// drinks is recast without another rest.
|
|
663
|
+
const prep = prepOoc(g, ds);
|
|
664
|
+
const buffed = prepText(prep);
|
|
665
|
+
if (buffed && !restedForBuffs && cfg.restMode === 'eat-then-wait' && prep.manaPct < cfg.recoverToPct) {
|
|
666
|
+
restedForBuffs = true; prog.recovering = true; lastKind = 'buff';
|
|
667
|
+
line = `${buffed} ${dim('rest before the pull')}`;
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
restedForBuffs = false;
|
|
671
|
+
if (buffed) out(`${dim(String(i).padStart(3) + '.')} ${buffed}`);
|
|
656
672
|
if (liveFight) {
|
|
657
673
|
const started = liveFight(step, (r) => settle(r, step));
|
|
658
674
|
if (!started.ok) { sets.blockedFights.add(step.creatureEntry); line = red(`fight ${c?.name}: ${started.reason}`); }
|
package/src/cli/sixel.mjs
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
//
|
|
10
10
|
// The pixels come from model-art-hd.bin.br (scripts/bake-model-art.mjs --hd): the same sprites as the
|
|
11
11
|
// half-block pack at the renders' own size, up to 128 px wide; icons from icon-art-hd.bin.br. Neither ships in
|
|
12
|
-
// the package: hdArt.mjs finds them (downloaded, or a checkout's bake).
|
|
13
|
-
// and
|
|
12
|
+
// the package: hdArt.mjs finds them (downloaded, or a checkout's bake). model-art-worn.bin.br does ship: the
|
|
13
|
+
// forms and costumes a player wears, read for any sprite the hd pack lacks. No pack, no sprite, or no sixel:
|
|
14
|
+
// null, and the caller draws the half-block art. AZSH_SIXEL=0 turns it off, AZSH_SIXEL=1 on without asking.
|
|
14
15
|
import { readFileSync } from 'node:fs';
|
|
15
16
|
import { brotliDecompressSync } from 'node:zlib';
|
|
16
17
|
import { useColor } from './text.mjs';
|
|
@@ -36,35 +37,46 @@ export function noteDeviceAttributes(params) {
|
|
|
36
37
|
export function noteCellSize(h, w) {
|
|
37
38
|
if (h > 0 && w > 0) { cellH = h; cellW = w; }
|
|
38
39
|
}
|
|
40
|
+
/** The cell in pixels, { w, h }: what a caller fitting its own picture to a pane sizes by. */
|
|
41
|
+
export const cellSize = () => ({ w: cellW, h: cellH });
|
|
39
42
|
|
|
40
43
|
// ---- the source images ------------------------------------------------------------------------------
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
let
|
|
45
|
+
const WORN_URL = new URL('../data/model-art-worn.bin.br', import.meta.url);
|
|
46
|
+
let index = null; // displayId -> { pack, off }: the sprite's bytes (the half-block pack's layout, AZART v1)
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
index = new Map();
|
|
48
|
+
/** Index one pack's sprites; an id already indexed keeps the pack it came from. */
|
|
49
|
+
function indexPack(file) {
|
|
48
50
|
try {
|
|
49
|
-
pack = brotliDecompressSync(readFileSync(
|
|
50
|
-
if (pack.toString('ascii', 0, 5) !== 'AZART' || pack[5] !== 1) return
|
|
51
|
+
const pack = brotliDecompressSync(readFileSync(file));
|
|
52
|
+
if (pack.toString('ascii', 0, 5) !== 'AZART' || pack[5] !== 1) return;
|
|
51
53
|
const count = pack.readUInt32LE(6);
|
|
52
54
|
let off = 10;
|
|
53
55
|
for (let i = 0; i < count && off + 7 <= pack.length; i++) {
|
|
54
|
-
|
|
56
|
+
const id = pack.readUInt32LE(off);
|
|
57
|
+
if (!index.has(id)) index.set(id, { pack, off });
|
|
55
58
|
off += 7 + pack[off + 6] * 3 + pack[off + 4] * pack[off + 5];
|
|
56
59
|
}
|
|
57
|
-
} catch { pack
|
|
60
|
+
} catch { /* no pack: its sprites draw half-block */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function load() {
|
|
64
|
+
if (index) return index;
|
|
65
|
+
index = new Map();
|
|
66
|
+
const hd = hdArtPath('model-art-hd.bin.br');
|
|
67
|
+
if (hd) indexPack(hd);
|
|
68
|
+
indexPack(WORN_URL);
|
|
58
69
|
return index;
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
/** Forget the packs (and what was missing) so the next draw reads them again: after a download. */
|
|
62
|
-
export function forgetSixelPacks() { index =
|
|
73
|
+
export function forgetSixelPacks() { index = iconIndex = iconPack = null; images.clear(); byKey.clear(); }
|
|
63
74
|
|
|
64
75
|
/** The sprite for a display id as { w, h, rgba } (already cropped to its opaque box), or null. */
|
|
65
76
|
function sourceOf(displayId) {
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
77
|
+
const at = load().get(displayId);
|
|
78
|
+
if (!at) return null;
|
|
79
|
+
const { pack, off } = at;
|
|
68
80
|
const w = pack[off + 4], h = pack[off + 5], palLen = pack[off + 6], pal = off + 7, idx = pal + palLen * 3;
|
|
69
81
|
const rgba = new Uint8Array(w * h * 4);
|
|
70
82
|
for (let i = 0; i < w * h; i++) {
|
package/src/cli/text.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Text helpers shared by the line REPL and the windowed TUI: quest text codes, wrapping, fuzzy
|
|
2
2
|
// picking, ANSI colour and visible-width maths. Nothing here touches game state.
|
|
3
3
|
import { className, raceName } from '../domain/core/ids.ts';
|
|
4
|
+
import { coinParts, moneyText } from '../domain/economy/money.ts';
|
|
4
5
|
|
|
5
6
|
export const useColor = !process.env.NO_COLOR;
|
|
6
7
|
// A reset inside the painted text (a coloured item name in a green sentence) re-opens this paint,
|
|
@@ -25,6 +26,21 @@ export function qualityPaint(quality) { return QUALITY_PAINT[quality ?? 1] ?? wh
|
|
|
25
26
|
/** An item record's name in its quality colour; `fallback` when the record is missing. */
|
|
26
27
|
export function itemText(it, fallback = 'item') { return qualityPaint(it?.quality)(it?.name ?? fallback); }
|
|
27
28
|
|
|
29
|
+
// The client's coin colours. A count is followed by its coin, as the money frame draws it: 1● 20● 5●.
|
|
30
|
+
const COIN_RGB = { g: [255, 210, 0], s: [205, 205, 215], c: [205, 120, 55] };
|
|
31
|
+
const COIN = Object.fromEntries(Object.entries(COIN_RGB).map(([k, [r, g, b]]) => [k, `\x1b[38;2;${r};${g};${b}m●\x1b[0m`]));
|
|
32
|
+
/** One coin, 'g', 's' or 'c', for a count written by hand; the letter without colour. */
|
|
33
|
+
export const coin = (k) => (useColor ? COIN[k] : k);
|
|
34
|
+
/** Copper as counts and coins (gold, silver, copper in their colours), an empty copper left off after a
|
|
35
|
+
* bigger coin as the client does; `zero` stands in for nothing when given ('free'). Without colour the
|
|
36
|
+
* letters: 1g 20s 5c. The counts take whatever paint is round them. */
|
|
37
|
+
export function coinText(copper, zero) {
|
|
38
|
+
if (zero != null && !(copper > 0)) return zero;
|
|
39
|
+
if (!useColor) return moneyText(copper);
|
|
40
|
+
const parts = coinParts(copper);
|
|
41
|
+
return parts.filter((p, i) => p.n || i === 0).map((p) => `${p.n}${COIN[p.k]}`).join(' ');
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
const GENDER_TOKEN = /\$[Gg]\s*([^:]*):([^;]*);/g;
|
|
29
45
|
/** $N name, $C class, $R race, $B break, $G male:female; (SharedDefines.h:26). Same codes as phonecraft questText. */
|
|
30
46
|
export function questText(raw, ch) {
|
package/src/cli/tui.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { honorDialog, weeklyHonorPass } from './tuiHonor.mjs';
|
|
|
24
24
|
import { devCommand } from './tuiDev.mjs';
|
|
25
25
|
import { savePicker } from './tuiSaves.mjs';
|
|
26
26
|
import { wikiDialog } from './tuiWiki.mjs';
|
|
27
|
-
import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct, brightYellow, magenta, brightBlue, strip, qualityPaint } from './text.mjs';
|
|
27
|
+
import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct, brightYellow, magenta, brightBlue, strip, qualityPaint, coinText } from './text.mjs';
|
|
28
28
|
import { iconsOn, iconArt, iconBlank, wrapIconLines } from './iconArt.mjs';
|
|
29
29
|
import { createGame } from '../engine/game.ts';
|
|
30
30
|
import { toSave } from '../persistence/saveShape.ts';
|
|
@@ -33,7 +33,7 @@ import { characterCreation } from './tuiCreate.mjs';
|
|
|
33
33
|
import { missingHdBytes, downloadHdArt, downloadDir } from './hdArt.mjs';
|
|
34
34
|
import { forgetHdArt } from './modelArt.mjs';
|
|
35
35
|
import { sixelOn } from './sixel.mjs';
|
|
36
|
-
import {
|
|
36
|
+
import { coinsText } from '../domain/economy/money.ts';
|
|
37
37
|
import { maxHealth, maxPower } from '../domain/character/character.ts';
|
|
38
38
|
import { powerType } from '../domain/character/characterStats.ts';
|
|
39
39
|
import { addItem } from '../domain/items/inventory.ts';
|
|
@@ -55,6 +55,7 @@ const HELP_LINES = [
|
|
|
55
55
|
' a in the search (or S on World) widens it to the whole world. Enter goes or opens.',
|
|
56
56
|
' z sit and rest 30 s ? this help',
|
|
57
57
|
' Ctrl-C / :quit save and leave Ctrl-L repaint a garbled screen',
|
|
58
|
+
' c (World tab) / :logout save and go back to the character list',
|
|
58
59
|
'',
|
|
59
60
|
'In the world',
|
|
60
61
|
' Enter on a creature starts a live fight; f fights it instantly. Enter on a quest, vendor, trainer or',
|
|
@@ -75,22 +76,23 @@ const HELP_LINES = [
|
|
|
75
76
|
' W walks the floor instead: pick a place, the party walks the nav mesh, patrols walk, and what sees you pulls.',
|
|
76
77
|
'',
|
|
77
78
|
'Gear and bags',
|
|
78
|
-
'
|
|
79
|
+
' Character tab: your gear around you. Enter on a slot lists everything in your bags (and the bank, in a bank town) that fits it.',
|
|
80
|
+
' Your stats sit under the tooltip; s opens the rest: weapon skills, professions, auras, home, pet, honor, reputation.',
|
|
79
81
|
' ▲ marks an upgrade the equipment helper found; u puts on every upgrade at once, x takes a piece off.',
|
|
80
82
|
' r repairs one piece, R everything; o opens the Outfitter (gear sets). :outfit <name> wears a set from anywhere.',
|
|
81
|
-
' In a big enough window the
|
|
83
|
+
' In a big enough window the Character tab is a paperdoll (the Talents tab the three trees): Up and Down walk the icons in order, Shift+arrows move across them.',
|
|
82
84
|
' Bags tab: Enter uses, equips, opens, unwraps or learns; m lists every verb for the stack. s sells to the open',
|
|
83
85
|
' vendor, j sells junk, b opens the bank, d destroys, S sorts, / splits a stack, [ ] move it, A uses the whole stack.',
|
|
84
86
|
' D lists every green, blue or epic the group can disenchant; L lists every book someone can learn. G runs it.',
|
|
85
87
|
' :dev item|quest|recipe|prof skips the rules (testing). New character offers a random world bot.',
|
|
86
|
-
' :settings sets auto-loot, the upgrade helper, auto-disenchant, the junk rules and which nodes get gathered.',
|
|
88
|
+
' Settings tab (or :settings) sets auto-loot, the upgrade helper, auto-disenchant, the junk rules and which nodes get gathered.',
|
|
87
89
|
' Spells tab: b edits your action bars, m your macros. :macro <name> runs one; a macro on bar 1 fires by its key in a fight.',
|
|
88
90
|
' Loot: with :autoloot off a corpse opens the loot window. Enter takes a row, a takes all, Esc leaves the rest.',
|
|
89
91
|
'',
|
|
90
92
|
'In a fight',
|
|
91
93
|
' 1-9 press an ability a toggle auto-play space pause + - speed l log Esc run away',
|
|
92
94
|
' t gives a companion an order: attack a target, cast one ability, or hold ground (the fight waits).',
|
|
93
|
-
' c cancels one of your buffs (also from the Character tab, out of a fight).',
|
|
95
|
+
' c cancels one of your buffs (also from s on the Character tab, out of a fight).',
|
|
94
96
|
' r (in a fight you started with Enter) fights the same creature again after each win, until r again or Esc.',
|
|
95
97
|
' The ability bar runs along the bottom of the stage with its cooldowns; a takes the keys off the rotation.',
|
|
96
98
|
'',
|
|
@@ -239,6 +241,22 @@ export class App {
|
|
|
239
241
|
this.onQuit?.();
|
|
240
242
|
}
|
|
241
243
|
|
|
244
|
+
/** Save, let go of the character and go back to the character picker. Not in the middle of a fight or a run. */
|
|
245
|
+
async logout() {
|
|
246
|
+
if (!this.g) return;
|
|
247
|
+
if (this.fight || this.auto) { this.flashMsg('Finish the fight or stop the run first.'); this.render(); return; }
|
|
248
|
+
for (const t of this.timers) this.term.clearInterval(t);
|
|
249
|
+
this.timers = [];
|
|
250
|
+
await this.persist();
|
|
251
|
+
if (this.slot != null) this.repo.unlock?.(this.slot);
|
|
252
|
+
this.say(dim(`Saved ${this.g.state.char.name}.`));
|
|
253
|
+
this.g = null; this.slot = null; this.cmds = null; this.vendor = null;
|
|
254
|
+
this.tabs = []; this.tab = 0; this.stack = [];
|
|
255
|
+
this.meter = null; this.lootTally = null; this.lastActivity = null;
|
|
256
|
+
await this.toPicker?.();
|
|
257
|
+
this.render();
|
|
258
|
+
}
|
|
259
|
+
|
|
242
260
|
// ---- small helpers the views lean on -------------------------------------------------------------
|
|
243
261
|
|
|
244
262
|
get ch() { return this.g.state.char; }
|
|
@@ -291,7 +309,7 @@ export class App {
|
|
|
291
309
|
pick(title, options, onPick, { hint, keepOpen } = {}) {
|
|
292
310
|
const view = {
|
|
293
311
|
id: 'pick', title, hint: hint ?? 'Enter: choose Esc: back',
|
|
294
|
-
rows: () => options.map((o) => ({ text: o.label, data: o.value, detail: o.detail ? () => (typeof o.detail === 'function' ? o.detail() : o.detail) : undefined, act: () => { if (!keepOpen) this.pop(); onPick(o.value, o); } })),
|
|
312
|
+
rows: () => options.map((o) => ({ text: o.label, icon: o.icon, sub: o.sub, data: o.value, detail: o.detail ? () => (typeof o.detail === 'function' ? o.detail() : o.detail) : undefined, act: () => { if (!keepOpen) this.pop(); onPick(o.value, o); } })),
|
|
295
313
|
noDetail: !options.some((o) => o.detail),
|
|
296
314
|
};
|
|
297
315
|
this.push(view);
|
|
@@ -502,6 +520,7 @@ export class App {
|
|
|
502
520
|
const [, cmd = '', args = ''] = line.match(/^(\S+)\s*(.*)$/) ?? [];
|
|
503
521
|
const c = cmd.toLowerCase();
|
|
504
522
|
if (c === 'quit' || c === 'exit' || c === 'q') { await this.quit(); return; }
|
|
523
|
+
if (c === 'logout' || c === 'characters') { await this.logout(); return; }
|
|
505
524
|
if (c === 'auto') { const m = args.match(/^(\d+)?\s*(?:farm\s+(.+))?$/i); startAutopilot(this, m?.[1] ? Number(m[1]) : Infinity, m?.[2]); return; }
|
|
506
525
|
if (c === 'art') {
|
|
507
526
|
const missing = missingHdBytes();
|
|
@@ -610,7 +629,7 @@ export class App {
|
|
|
610
629
|
const dead = this.isDead() ? red(bold(' DEAD')) : ch.mountedItem != null ? dim(' mounted') : '';
|
|
611
630
|
const title = ` ${bold(ch.name)} ${dim(`L${ch.level} ${raceName(ch.race)} ${className(ch.cls)}`)}${dead}`;
|
|
612
631
|
const road = journeyHeader(this);
|
|
613
|
-
const right = `${road ? yellow(road) : cyan(z?.name ?? ch.zoneId)} ${
|
|
632
|
+
const right = `${road ? yellow(road) : cyan(z?.name ?? ch.zoneId)} ${coinText(ch.gold)} `;
|
|
614
633
|
const l0 = fit(title, L.W - vis(right)) + right;
|
|
615
634
|
// In a fight the bars follow the live session, so the header is the player's frame and no panel needs another.
|
|
616
635
|
const live = this.fight?.session?.playerView?.() ?? null;
|
|
@@ -690,7 +709,11 @@ export class App {
|
|
|
690
709
|
if (showDetail) {
|
|
691
710
|
const d = cursorRow?.detail?.(this, cursorRow) ?? view.detail?.(this, cursorRow) ?? [];
|
|
692
711
|
detailLines = wrapIconLines(d, L.detailW - 2);
|
|
693
|
-
|
|
712
|
+
// A view's foot (the Character tab's stats) sits at the bottom of the pane when the tooltip keeps room above it.
|
|
713
|
+
const foot = view.detailFoot ? wrapIconLines(view.detailFoot(this, L.detailW - 2), L.detailW - 2) : [];
|
|
714
|
+
const room = foot.length && L.mainH - foot.length >= 16 ? L.mainH - foot.length - 1 : L.mainH;
|
|
715
|
+
if (detailLines.length > room) detailLines = detailLines.slice(0, room - 1).concat(dim('…'));
|
|
716
|
+
if (room < L.mainH) detailLines = [...detailLines, ...Array(L.mainH - foot.length - detailLines.length).fill(''), ...foot];
|
|
694
717
|
}
|
|
695
718
|
for (let i = 0; i < L.mainH; i++) {
|
|
696
719
|
const left = fit(listLines[i] ?? '', listW);
|
|
@@ -766,6 +789,7 @@ export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
|
|
|
766
789
|
app.render();
|
|
767
790
|
};
|
|
768
791
|
const reopenPicker = () => savePicker(app, { onPlay: (save) => { void begin(save); }, onNew: createFlow });
|
|
792
|
+
app.toPicker = reopenPicker;
|
|
769
793
|
const createFlow = () => characterCreation(app, (state) => {
|
|
770
794
|
app.stack = [];
|
|
771
795
|
app.startGame(state, `${state.char.name.toLowerCase()}-${Date.now().toString(36)}`, Date.now(), true);
|
package/src/cli/tuiAutopilot.mjs
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// you browse, the run panel between fights (now, next, quests, this run's numbers, what stops it), the Run
|
|
3
3
|
// pane at the bottom (one row per planner step) and the end card. The run itself is the controller in
|
|
4
4
|
// tuiFight.mjs (startAutopilot); the planner is repl.mjs createAutopilot. Everything here only reads.
|
|
5
|
-
import { bold, dim, cyan, yellow, green, red, magenta, inverse, fit, vis, cut, bar, strip, pct, secondsText, wrapLines } from './text.mjs';
|
|
5
|
+
import { bold, dim, cyan, yellow, green, red, magenta, inverse, fit, vis, cut, bar, strip, pct, secondsText, wrapLines, coinText } from './text.mjs';
|
|
6
6
|
import { idlePlayerFrame, startAutopilot, fightArt, flankArt } from './tuiFight.mjs';
|
|
7
7
|
import { objectiveLines, questDialog } from './tuiViews.mjs';
|
|
8
8
|
import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
|
|
9
9
|
import { squadModeOf } from './squad.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { iconsOn, iconArt, iconBlank } from './iconArt.mjs';
|
|
11
11
|
import { maxHealth, maxPower } from '../domain/character/character.ts';
|
|
12
12
|
|
|
13
13
|
const H = (text) => ({ text, header: true, plain: true });
|
|
@@ -15,7 +15,7 @@ const H = (text) => ({ text, header: true, plain: true });
|
|
|
15
15
|
const wrapRows = (lead, text, W) => wrapLines(text, Math.max(20, W - 13)).map((l, i) => H(`${i ? ' '.repeat(11) : lead}${l}`));
|
|
16
16
|
const num = (n) => Math.round(n).toLocaleString('en-US');
|
|
17
17
|
const clock = (ms) => { const s = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
|
|
18
|
-
const signedMoney = (copper) => (copper < 0 ? `-${
|
|
18
|
+
const signedMoney = (copper) => (copper < 0 ? `-${coinText(-copper)}` : `+${coinText(copper)}`);
|
|
19
19
|
|
|
20
20
|
/** "questing in Redridge Mountains", "farming Redridge Mystic in …", "doing A Baying of Gnolls". */
|
|
21
21
|
export function runWhat(app, ctl) {
|
|
@@ -149,14 +149,23 @@ export function bagsPaneLines(app, L) {
|
|
|
149
149
|
const items = rows.map((r, i) => i).filter((i) => !rows[i].header);
|
|
150
150
|
if (!items.length) return [` ${bold('Bags')}`, dim(' Nothing in your bags.')];
|
|
151
151
|
if (!items.includes(st.cursor)) st.cursor = items.find((i) => i >= st.cursor) ?? items[items.length - 1];
|
|
152
|
-
|
|
152
|
+
// With icons a row is two lines like the Bags tab's list: the icon down the left, the name beside the top
|
|
153
|
+
// and the row's kind and price beside the bottom.
|
|
154
|
+
const icons = iconsOn() && L.W >= 40;
|
|
155
|
+
const room = Math.floor((L.logH - 1) / (icons ? 2 : 1));
|
|
153
156
|
const at = items.indexOf(st.cursor);
|
|
154
157
|
const start = Math.max(0, Math.min(at - Math.floor(room / 2), items.length - room));
|
|
155
158
|
const lines = [` ${bold('Bags')} ${dim('Up Down: pick Enter: use, equip, open the run plays on')}`];
|
|
156
159
|
for (const i of items.slice(start, start + room)) {
|
|
157
|
-
const r = rows[i];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
const r = rows[i], sel = i === st.cursor;
|
|
161
|
+
if (icons) {
|
|
162
|
+
const art = iconArt(r.icon, 4) ?? iconBlank(4);
|
|
163
|
+
const t1 = fit(` ${r.text}`, L.W - 6), t2 = fit(` ${r.sub ?? ''}`, L.W - 6);
|
|
164
|
+
lines.push(`${sel ? '▶' : ' '}${art[0]}${sel ? inverse(t1) : t1}`, ` ${art[1]}${sel ? inverse(t2) : t2}`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const text = fit(sel ? `▶ ${r.text}` : ` ${r.text}`, L.W - 1);
|
|
168
|
+
lines.push(sel ? inverse(text) : ` ${text}`);
|
|
160
169
|
}
|
|
161
170
|
return lines;
|
|
162
171
|
}
|
|
@@ -229,7 +238,7 @@ function runLine(app, ctl) {
|
|
|
229
238
|
// ---- the Run pane ----------------------------------------------------------------------------------
|
|
230
239
|
|
|
231
240
|
const GLYPH = {
|
|
232
|
-
fight: red('⚔'), wait: dim('~'), consume: dim('~'), cannibalize: dim('~'), turnin: green('✓'), accept: green('+'), travel: cyan('→'),
|
|
241
|
+
fight: red('⚔'), buff: cyan('↑'), wait: dim('~'), consume: dim('~'), cannibalize: dim('~'), turnin: green('✓'), accept: green('+'), travel: cyan('→'),
|
|
233
242
|
gather: yellow('⚒'), skin: yellow('⚒'), buy: yellow('$'), equip: green('▲'), skip: yellow('✗'), dead: red('☠'), stop: yellow('■'),
|
|
234
243
|
};
|
|
235
244
|
|