azeroth-shell 0.1.0 → 0.1.1

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 CHANGED
@@ -1,13 +1,13 @@
1
1
  # azsh
2
2
 
3
- Azeroth shell. A WoW Classic 1.12 text RPG that runs in the terminal, built on the same
4
- CMaNGOS Classic data and the same headless engine as [phonecraft](../phonecraft). No graphics,
5
- no browser, no build step.
3
+ Azeroth shell. A WoW Classic 1.12 text RPG that runs in the terminal, built on
4
+ CMaNGOS Classic data. The engine comes from another project of mine that is not released; this is its
5
+ terminal front end. No graphics, no browser, no build step.
6
6
 
7
7
  ```
8
8
  npx azeroth-shell # run it straight from npm
9
9
  npm install -g azeroth-shell # or install it once, then:
10
- azsh # pick a character or roll one
10
+ azeroth-shell # pick a character or roll one (azsh for short)
11
11
  ```
12
12
 
13
13
  From a checkout: `npm install -g .` or `node bin/azsh.mjs`.
@@ -114,17 +114,17 @@ Saves live in `~/.azsh/saves/`, one JSON file per character. `AZSH_HOME` moves t
114
114
 
115
115
  ## Where it comes from
116
116
 
117
- | Layer | Source | In this tree |
118
- | --- | --- | --- |
119
- | Engine, rules, world, quests, autopilot | phonecraft `src/engine`, `src/domain`, `src/world`, `src/quests`, `src/progression`, `src/autopilot` | copied as-is |
120
- | Data | phonecraft `src/data` (CMaNGOS world DB + DBC bake) | every table gzipped on disk (`src/cli/gzLoader.mjs` serves static imports), audio tables dropped |
121
- | Front end | new | `src/cli/` — `tui.mjs` (frame, input, dialogs), `tuiViews.mjs` (tabs), `tuiProfessions.mjs` and `tuiCompanions.mjs` (crafting, companions), `tuiFight.mjs` (live fight, autopilot), `repl.mjs` (line game and the shared verb table) |
122
- | Combat log wording | phonecraft `src/ui/combat/combatLogFormat.ts` | `src/cli/combatLogFormat.ts` |
123
- | Rotation sentences | phonecraft `src/ui/rotation/rotationEditorSentence.ts` | `src/cli/rotationText.mjs` |
124
- | Dungeon floor scripts (Baron Run; gauntlets, waves, escorts) | phonecraft `src/ui/live/liveStratUndead.ts`, `liveGauntletEscort.ts` | `src/cli/`, copied by `scripts/sync-phonecraft.mjs`; `tuiFloor.mjs` gives them their context |
117
+ The engine, rules, world, quests, autopilot and data are taken as-is from a larger, unreleased project
118
+ of mine that runs the same game with a graphical front end. The data is the CMaNGOS Classic world
119
+ database plus a DBC bake, every table gzipped on disk (`src/cli/gzLoader.mjs` serves the static
120
+ imports). What is new here is the terminal front end in `src/cli/`: `tui.mjs` (frame, input, dialogs),
121
+ `tuiViews.mjs` (tabs), `tuiProfessions.mjs` and `tuiCompanions.mjs` (crafting, companions),
122
+ `tuiFight.mjs` (live fight, autopilot) and `repl.mjs` (line game and the shared verb table). A few
123
+ small UI modules (combat log wording, rotation sentences, dungeon floor scripts) are ported from the
124
+ same project.
125
125
 
126
126
  Everything the player reads traces to a CMaNGOS row or a 1.12 client string. Nothing here is
127
- invented; see phonecraft's `AI.md` for the data rules this tree inherits.
127
+ invented.
128
128
 
129
129
  ## Checks
130
130
 
package/package.json CHANGED
@@ -1,10 +1,19 @@
1
1
  {
2
2
  "name": "azeroth-shell",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
- "description": "Azeroth shell. A WoW Classic 1.12 text RPG for the terminal, built on CMaNGOS data. Terminal fork of phonecraft.",
6
- "keywords": ["wow", "classic", "rpg", "terminal", "game", "tui", "cmangos"],
5
+ "description": "Azeroth shell. A WoW Classic 1.12 text RPG for the terminal, built on CMaNGOS data.",
6
+ "keywords": [
7
+ "wow",
8
+ "classic",
9
+ "rpg",
10
+ "terminal",
11
+ "game",
12
+ "tui",
13
+ "cmangos"
14
+ ],
7
15
  "bin": {
16
+ "azeroth-shell": "bin/azsh.mjs",
8
17
  "azsh": "bin/azsh.mjs"
9
18
  },
10
19
  "files": [
package/src/cli/term.mjs CHANGED
@@ -9,6 +9,7 @@ import { stdin, stdout } from 'node:process';
9
9
  import { fit } from './text.mjs';
10
10
 
11
11
  const ESC = '\x1b[';
12
+ const MIN_COLS = 60, MIN_ROWS = 20;
12
13
 
13
14
  const CSI_FINAL = { A: 'up', B: 'down', C: 'right', D: 'left', H: 'home', F: 'end', Z: 'shift-tab', P: 'f1', Q: 'f2', R: 'f3', S: 'f4' };
14
15
  const CSI_TILDE = { 1: 'home', 2: 'insert', 3: 'delete', 4: 'end', 5: 'pageup', 6: 'pagedown', 7: 'home', 8: 'end', 11: 'f1', 12: 'f2', 13: 'f3', 14: 'f4', 15: 'f5', 17: 'f6', 18: 'f7', 19: 'f8', 20: 'f9', 21: 'f10', 23: 'f11', 24: 'f12' };
@@ -64,40 +65,57 @@ export class RealTerm {
64
65
  this.isTTY = stdout.isTTY === true && stdin.isTTY === true;
65
66
  this.last = [];
66
67
  }
67
- get cols() { return Math.max(60, stdout.columns || 100); }
68
- get rows() { return Math.max(20, stdout.rows || 32); }
68
+ /** Layout size: never below the minimum the screens are laid out for (see `tooSmall`). */
69
+ get cols() { return Math.max(MIN_COLS, stdout.columns || 100); }
70
+ get rows() { return Math.max(MIN_ROWS, stdout.rows || 32); }
71
+ get tooSmall() { return (stdout.columns || 100) < MIN_COLS || (stdout.rows || 32) < MIN_ROWS; }
69
72
  open() {
70
73
  if (stdin.isTTY) stdin.setRawMode(true);
71
74
  stdin.setEncoding('utf8');
72
75
  stdin.resume();
73
- stdout.write(`${ESC}?1049h${ESC}?25l${ESC}H${ESC}2J`);
74
- this._data = (chunk) => { for (const k of decodeKeys(chunk)) this.onKey(k); };
75
- this._resize = () => { this.last = []; this.onResize(); };
76
+ // Autowrap off: a line a terminal measures wider than we do (an ambiguous-width glyph) is clipped
77
+ // instead of wrapping and scrolling the whole frame out of step with `last`.
78
+ stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?7l${ESC}H${ESC}2J`);
79
+ // Ctrl-L forgets what is on screen and repaints everything, to recover from a terminal that moved it.
80
+ this._data = (chunk) => { for (const k of decodeKeys(chunk)) { if (k === 'ctrl-l') this.refresh(); else this.onKey(k); } };
81
+ this._resize = () => this.refresh();
76
82
  stdin.on('data', this._data);
77
83
  stdout.on('resize', this._resize);
78
84
  }
79
85
  close() {
80
86
  stdin.off('data', this._data);
81
87
  stdout.off('resize', this._resize);
82
- stdout.write(`${ESC}?25h${ESC}?1049l`);
88
+ stdout.write(`${ESC}0m${ESC}?7h${ESC}?25h${ESC}?1049l`);
83
89
  if (stdin.isTTY) stdin.setRawMode(false);
84
90
  stdin.pause();
85
91
  }
86
- /** Paint a whole frame. Lines are fitted to the width; unchanged lines are skipped. The last column stays
87
- * blank so a terminal without deferred wrap never scrolls the frame when the bottom row is painted. */
92
+ refresh() { this.last = []; this.onResize(); }
93
+ /** Paint a whole frame. Lines are fitted to the width; unchanged lines are skipped, unless most of the frame
94
+ * changed (a new screen), which repaints it all so a stale row cannot outlive a screen change. The last
95
+ * column stays blank so a terminal without deferred wrap never scrolls when the bottom row is painted. */
88
96
  draw(lines) {
97
+ if (this.tooSmall) { this.drawTooSmall(); return; }
89
98
  const w = this.cols - 1, h = this.rows;
90
- let out = '';
91
99
  const next = [];
92
- for (let i = 0; i < h; i++) {
93
- const line = fit(lines[i] ?? '', w);
94
- next.push(line);
95
- if (this.last[i] === line) continue;
96
- out += `${ESC}${i + 1};1H${ESC}0m${line}`;
97
- }
100
+ for (let i = 0; i < h; i++) next.push(fit(lines[i] ?? '', w));
101
+ const changed = next.filter((line, i) => this.last[i] !== line).length;
102
+ const full = !this.last.length || changed * 2 > h;
103
+ // Every row is padded to the width, so only a frame with nothing known on screen needs a clear first.
104
+ let out = this.last.length ? '' : `${ESC}0m${ESC}H${ESC}2J`;
105
+ next.forEach((line, i) => { if (full || this.last[i] !== line) out += `${ESC}${i + 1};1H${ESC}0m${line}`; });
98
106
  this.last = next;
99
107
  if (out) stdout.write(out);
100
108
  }
109
+ /** The screens need MIN_COLS x MIN_ROWS; a smaller terminal gets a notice instead of a frame drawn off its edge. */
110
+ drawTooSmall() {
111
+ const c = stdout.columns || 100, r = stdout.rows || 32;
112
+ const msg = [`Terminal is ${c}x${r}.`, `azsh needs at least ${MIN_COLS}x${MIN_ROWS}.`, 'Make the window larger.'];
113
+ const top = Math.max(0, Math.floor((r - msg.length) / 2));
114
+ let out = `${ESC}0m${ESC}H${ESC}2J`;
115
+ msg.forEach((m, i) => { if (top + i < r) out += `${ESC}${top + i + 1};${Math.max(1, Math.floor((c - m.length) / 2) + 1)}H${m.slice(0, c)}`; });
116
+ this.last = [];
117
+ stdout.write(out);
118
+ }
101
119
  /** Delay helper for real-time loops. */
102
120
  setInterval(fn, ms) { return setInterval(fn, ms); }
103
121
  clearInterval(h) { clearInterval(h); }
package/src/cli/tui.mjs CHANGED
@@ -46,7 +46,7 @@ const HELP_LINES = [
46
46
  ' s (World tab) find a thing in the world: who sells an item and where, what drops it, where an',
47
47
  ' ore or herb grows, a dungeon entrance, an NPC. Enter travels there or opens it.',
48
48
  ' z sit and rest 30 s ? this help',
49
- ' Ctrl-C / :quit save and leave',
49
+ ' Ctrl-C / :quit save and leave Ctrl-L repaint a garbled screen',
50
50
  '',
51
51
  'In the world',
52
52
  ' Enter on a creature starts a live fight; f fights it instantly. Enter on a quest, vendor, trainer or',