azeroth-shell 0.2.0 → 0.4.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/CHANGELOG.md +53 -0
- package/README.md +43 -10
- package/bin/azsh.mjs +15 -1
- package/package.json +7 -3
- 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 +40 -13
- package/src/cli/tuiAutopilot.mjs +17 -8
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBulk.mjs +83 -4
- package/src/cli/tuiCheck.mjs +328 -19
- package/src/cli/tuiCity.mjs +31 -31
- package/src/cli/tuiCompanionKit.mjs +26 -8
- package/src/cli/tuiCompanions.mjs +189 -29
- package/src/cli/tuiCreate.mjs +7 -0
- package/src/cli/tuiDungeon.mjs +113 -33
- 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 +374 -85
- 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/professions.ts +4 -0
- package/src/engine/game/squadQuestShare.ts +13 -1
- package/src/engine/game/types.ts +4 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
What changed in each release of `azeroth-shell` on npm. Update with `npm install -g azeroth-shell@latest`.
|
|
4
|
+
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 0.4.0 — 2026-11-03
|
|
8
|
+
|
|
9
|
+
- Bags: `X` destroys several stacks at once, and `Q` destroys every quest item left over from quests you handed in.
|
|
10
|
+
- Dungeons: a 5-man you leave is held for two hours (it was 30 minutes). On the World tab, Enter on a run you
|
|
11
|
+
left half done takes you straight back in. The tab also lists the zone's dungeons while you are inside one,
|
|
12
|
+
and a cleared run says so. Picking another dungeon from inside one leaves the current run, which is held.
|
|
13
|
+
- Companions get their own copy of a quest item that comes from an object. If their copy never finished, they
|
|
14
|
+
drop it when you hand the quest in, so it no longer fills their log. Their quest list offers to drop old ones.
|
|
15
|
+
- Art for what a fight turns into or brings in: boss morphs, totems, wards and adds were invisible before.
|
|
16
|
+
- The title line reads "Azeroth Shell".
|
|
17
|
+
|
|
18
|
+
## 0.3.0 — 2026-11-03
|
|
19
|
+
|
|
20
|
+
- HD gear art at 256 px with a palette per body. Shapeshift forms and costumes draw sharp under sixel.
|
|
21
|
+
- The Travel tab has a world map, in half-block and sixel.
|
|
22
|
+
- Tame and quest-item uses on World rows; dungeon entry locks and extra entrances; Hearthstone and ring/trinket
|
|
23
|
+
picks in Bags. The Character tab gets a stats page, and buffs are cast before a pull.
|
|
24
|
+
- Hunter pets untrain at the trainer's prices and keep their innate abilities.
|
|
25
|
+
- A foe still targeting you keeps the fight going.
|
|
26
|
+
- `azsh --art` downloads the full-size art up front.
|
|
27
|
+
|
|
28
|
+
## 0.2.0 — 2026-11-02
|
|
29
|
+
|
|
30
|
+
- Art: creatures, icons and your geared character are drawn as half-block sprites, or as real pixels in a
|
|
31
|
+
terminal that supports sixel.
|
|
32
|
+
- A fight stage, the Gear tab as a character sheet, talent trees, and a one-page character creation screen.
|
|
33
|
+
- Party fights on the stage, each body at its real distance. Casters no longer stall on immune foes.
|
|
34
|
+
- The autopilot on screen: pre-flight card, run strip, run panel, Run/Quests/Bags panes, end card, and the
|
|
35
|
+
rotation condition editor.
|
|
36
|
+
- Auction house browsing by category; world search covers the zone you stand in.
|
|
37
|
+
- Warsong Gulch: strip map, your own seat, side calls, and matches that actually end.
|
|
38
|
+
- Faster start: the engine is cached under `~/.azsh/cache`, with a loading line while it imports.
|
|
39
|
+
|
|
40
|
+
## 0.1.2 — 2026-11-01
|
|
41
|
+
|
|
42
|
+
- A character open in another terminal cannot be opened a second time.
|
|
43
|
+
- The Travel tab stays open after you depart.
|
|
44
|
+
- A screen refresh setting: game loops repaint at most once per refresh.
|
|
45
|
+
|
|
46
|
+
## 0.1.1 — 2026-11-01
|
|
47
|
+
|
|
48
|
+
- Vendor window: `d` destroys a stack from your bags or a companion's.
|
|
49
|
+
- Terminal: Ctrl-L repaints, a full repaint on screen change, and a notice when the window is too small.
|
|
50
|
+
|
|
51
|
+
## 0.1.0 — 2026-11-01
|
|
52
|
+
|
|
53
|
+
- First release on npm.
|
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.4.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,7 +30,10 @@
|
|
|
30
30
|
"src/",
|
|
31
31
|
"!src/data/*-hd.bin.br",
|
|
32
32
|
"!src/data/gear-art-hd/",
|
|
33
|
-
"
|
|
33
|
+
"!src/data/*.only.bin.br",
|
|
34
|
+
"!src/data/gear-art-hd.only/",
|
|
35
|
+
"docs/",
|
|
36
|
+
"CHANGELOG.md"
|
|
34
37
|
],
|
|
35
38
|
"engines": {
|
|
36
39
|
"node": ">=22.18"
|
|
@@ -41,9 +44,10 @@
|
|
|
41
44
|
"check": "node --import ./src/cli/register.mjs src/cli/bootCheck.mjs",
|
|
42
45
|
"data:pack": "node scripts/pack-data.mjs",
|
|
43
46
|
"data:unpack": "node scripts/pack-data.mjs --unpack",
|
|
44
|
-
"art:bake": "node scripts/bake-model-art.mjs",
|
|
47
|
+
"art:bake": "node scripts/bake-model-art.mjs && node scripts/bake-model-art.mjs --worn",
|
|
45
48
|
"art:gear": "python scripts/bake-gear-art.py",
|
|
46
49
|
"art:icons": "node scripts/bake-icon-art.mjs",
|
|
50
|
+
"art:map": "node scripts/bake-world-map.mjs && node scripts/bake-world-map.mjs --sixel",
|
|
47
51
|
"art:hd": "node scripts/bake-model-art.mjs --hd && node scripts/bake-icon-art.mjs --hd && python scripts/bake-gear-art.py --hd",
|
|
48
52
|
"art:publish": "node scripts/publish-hd-art.mjs",
|
|
49
53
|
"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) {
|