azeroth-shell 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azeroth-shell",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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": [
@@ -12,6 +12,15 @@
12
12
  "tui",
13
13
  "cmangos"
14
14
  ],
15
+ "author": "Simen Lien",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/LienSimen/azsh.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/LienSimen/azsh/issues"
22
+ },
23
+ "homepage": "https://github.com/LienSimen/azsh#readme",
15
24
  "bin": {
16
25
  "azeroth-shell": "bin/azsh.mjs",
17
26
  "azsh": "bin/azsh.mjs"
@@ -25,6 +34,7 @@
25
34
  "node": ">=22.18"
26
35
  },
27
36
  "scripts": {
37
+ "test": "npm run check:all",
28
38
  "start": "node bin/azsh.mjs",
29
39
  "check": "node --import ./src/cli/register.mjs src/cli/bootCheck.mjs",
30
40
  "data:pack": "node scripts/pack-data.mjs",
@@ -1,5 +1,7 @@
1
1
  // SaveRepository on disk: one JSON file per slot under the saves dir (default ~/.azsh/saves).
2
2
  // Same seam as phonecraft's MemorySaveRepository: migrate() on load, summarize() for list().
3
+ // Two terminals may play two characters at once; a <slot>.lock file (holder's pid) keeps the same character
4
+ // from being opened twice, since each process would otherwise overwrite the other's progress on every save.
3
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'node:fs';
4
6
  import { homedir } from 'node:os';
5
7
  import { join } from 'node:path';
@@ -9,10 +11,16 @@ export const DEFAULT_SAVES_DIR = join(process.env.AZSH_HOME ?? join(homedir(), '
9
11
 
10
12
  const safeSlot = (slot) => String(slot).replace(/[^\w.-]/g, '_');
11
13
 
14
+ /** Signal 0 probes without killing: ESRCH means gone, EPERM means alive but not ours (still alive). */
15
+ const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; } };
16
+
12
17
  export class FileSaveRepository {
13
18
  constructor(dir = DEFAULT_SAVES_DIR) {
14
19
  this.dir = dir;
20
+ /** Slots this process holds the lock on; released on unlock() and when the process exits. */
21
+ this.held = new Set();
15
22
  mkdirSync(dir, { recursive: true });
23
+ process.on('exit', () => { for (const slot of this.held) this.unlock(slot); });
16
24
  }
17
25
  path(slot) { return join(this.dir, `${safeSlot(slot)}.json`); }
18
26
  async load(slot) {
@@ -22,10 +30,47 @@ export class FileSaveRepository {
22
30
  }
23
31
  async save(save) {
24
32
  const p = this.path(save.slot);
25
- const tmp = `${p}.tmp`;
33
+ // The temp name carries the pid so two processes saving different slots never trip over one another.
34
+ const tmp = `${p}.${process.pid}.tmp`;
26
35
  writeFileSync(tmp, JSON.stringify(save));
27
36
  renameSync(tmp, p);
28
37
  }
38
+
39
+ // ---- locks ------------------------------------------------------------------------------------------
40
+ // <slot>.lock holds `{ pid, at }`. A lock whose pid is gone (crash, killed window) is stale and taken over.
41
+
42
+ lockPath(slot) { return join(this.dir, `${safeSlot(slot)}.lock`); }
43
+
44
+ /** Pid of the live process playing this slot, or null when nobody is (a stale lock counts as nobody). */
45
+ lockedBy(slot) {
46
+ let info;
47
+ try { info = JSON.parse(readFileSync(this.lockPath(slot), 'utf8')); } catch { return null; }
48
+ const pid = Number(info?.pid);
49
+ if (!pid || pid === process.pid) return null;
50
+ return pidAlive(pid) ? pid : null;
51
+ }
52
+
53
+ /** Take the slot for this process. `{ ok: true }`, or `{ ok: false, pid }` when another live process has it. */
54
+ lock(slot) {
55
+ if (this.held.has(slot)) return { ok: true };
56
+ const p = this.lockPath(slot);
57
+ const body = JSON.stringify({ pid: process.pid, at: Date.now() });
58
+ for (let attempt = 0; attempt < 2; attempt++) {
59
+ try { writeFileSync(p, body, { flag: 'wx' }); this.held.add(slot); return { ok: true }; }
60
+ catch (err) { if (err.code !== 'EEXIST') throw err; }
61
+ const pid = this.lockedBy(slot);
62
+ if (pid) return { ok: false, pid };
63
+ try { unlinkSync(p); } catch { /* the other side may have just released it */ }
64
+ }
65
+ return { ok: false, pid: this.lockedBy(slot) };
66
+ }
67
+
68
+ /** Release a slot this process holds. Never touches another process's lock. */
69
+ unlock(slot) {
70
+ if (!this.held.has(slot)) return;
71
+ this.held.delete(slot);
72
+ try { unlinkSync(this.lockPath(slot)); } catch { /* already gone */ }
73
+ }
29
74
  async list() {
30
75
  const out = [];
31
76
  for (const f of readdirSync(this.dir)) {
package/src/cli/repl.mjs CHANGED
@@ -132,6 +132,9 @@ export async function main(argv = []) {
132
132
  state = await createCharacter(ds, ask);
133
133
  slot = `${state.char.name.toLowerCase()}-${Date.now().toString(36)}`;
134
134
  }
135
+ // Another terminal already playing this character would trade overwrites with us on every save.
136
+ const held = repo.lock(slot);
137
+ if (!held.ok) { console.log(red(`${state.char.name} is open in another terminal (pid ${held.pid}). Close it there first.`)); rl.close(); return; }
135
138
 
136
139
  const g = createGame(ds, state, { travelPace: 'instant' });
137
140
  if (g.state.char.journey) g.completeJourneyNow();
@@ -166,6 +169,7 @@ export async function main(argv = []) {
166
169
  try { await persist(); } catch (err) { console.log(red(`save failed: ${err.message}`)); }
167
170
  }
168
171
  await persist();
172
+ repo.unlock(slot);
169
173
  console.log(dim(`Saved ${g.state.char.name}. Farewell.`));
170
174
  rl.close();
171
175
  }
package/src/cli/tui.mjs CHANGED
@@ -13,7 +13,7 @@ import { openRun } from './tuiDungeon.mjs';
13
13
  import { openLoot } from './tuiLoot.mjs';
14
14
  import { fightPaneRule, fightPaneLines } from './tuiFightPanes.mjs';
15
15
  import { MemoryPrefs, FilePrefs } from './prefs.mjs';
16
- import { settingsDialog, globalSettingsOpts } from './tuiSettings.mjs';
16
+ import { settingsDialog, globalSettingsOpts, refreshMs } from './tuiSettings.mjs';
17
17
  import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
18
18
  import { macrosDialog, runMacro } from './tuiMacros.mjs';
19
19
  import { outfitterDialog } from './tuiOutfitter.mjs';
@@ -108,6 +108,7 @@ export class App {
108
108
  this.cmd = null;
109
109
  this.closed = false;
110
110
  this.timers = [];
111
+ this.lastPaint = 0; this.paintTimer = null; // renderSoon holds timer-driven repaints to the Screen refresh setting
111
112
  this.lastActivity = null;
112
113
  term.onKey = (k) => { try { this.onKey(k); } catch (err) { this.say(red(`Error: ${err.message}`)); if (process.env.AZSH_DEBUG) this.say(dim(String(err.stack).split('\n').slice(1, 4).join(' '))); this.render(); } };
113
114
  term.onResize = () => this.render();
@@ -117,6 +118,9 @@ export class App {
117
118
 
118
119
  /** `updatedAt` is when the save was last written: the gap since is offline rest and regen. */
119
120
  startGame(state, slot, createdAt, isNew, updatedAt) {
121
+ // Another terminal already playing this character would trade overwrites with us on every save.
122
+ const held = this.repo.lock?.(slot) ?? { ok: true };
123
+ if (!held.ok) { this.say(red(`${state.char.name} is open in another terminal (pid ${held.pid}). Close it there first.`)); return false; }
120
124
  this.slot = slot; this.createdAt = createdAt ?? Date.now();
121
125
  const awayMs = updatedAt > 0 ? Math.max(0, Date.now() - updatedAt) : 0;
122
126
  // Travel pace is a setting (instant by default, like phonecraft); `skipping` lets Arrive now land a trip at any pace.
@@ -173,8 +177,10 @@ export class App {
173
177
  if (this.closed) return;
174
178
  this.closed = true;
175
179
  for (const t of this.timers) this.term.clearInterval(t);
180
+ clearTimeout(this.paintTimer);
176
181
  this.fight?.stop?.(); this.auto?.stop?.();
177
182
  await this.persist();
183
+ if (this.slot != null) this.repo.unlock?.(this.slot);
178
184
  this.term.close();
179
185
  this.restoreConsole?.();
180
186
  if (this.g) console.log(dim(`Saved ${this.g.state.char.name}. Farewell.`));
@@ -453,8 +459,18 @@ export class App {
453
459
  return { W, H, logH, top, mainH, listW, detailW };
454
460
  }
455
461
 
462
+ /** A repaint from a game loop (a fight tick, the autopilot, a dungeon walk). The game keeps ticking every
463
+ * 100 ms; the screen catches up at most once per Screen refresh, and the last change is always painted. */
464
+ renderSoon() {
465
+ if (!this.term.isTTY) { this.render(); return; }
466
+ const wait = this.lastPaint + refreshMs(this.prefs) - Date.now();
467
+ if (wait <= 0) this.render();
468
+ else if (!this.paintTimer) this.paintTimer = setTimeout(() => { this.paintTimer = null; this.render(); }, wait);
469
+ }
470
+
456
471
  render() {
457
472
  if (this.closed) return;
473
+ clearTimeout(this.paintTimer); this.paintTimer = null; this.lastPaint = Date.now();
458
474
  const L = this.layout();
459
475
  const lines = [];
460
476
  lines.push(...this.headerLines(L));
@@ -580,16 +596,18 @@ export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
580
596
  const saves = await repo.list();
581
597
  const begin = async (save) => {
582
598
  app.stack = [];
583
- app.startGame(save.state, save.slot, save.createdAt, false, save.updatedAt);
599
+ // false: another terminal took this character between the picker's check and now; back to the picker.
600
+ if (app.startGame(save.state, save.slot, save.createdAt, false, save.updatedAt) === false) return reopenPicker();
584
601
  app.render();
585
602
  };
603
+ const reopenPicker = () => savePicker(app, { onPlay: (save) => { void begin(save); }, onNew: createFlow });
586
604
  const createFlow = () => characterCreation(app, (state) => {
587
605
  app.stack = [];
588
606
  app.startGame(state, `${state.char.name.toLowerCase()}-${Date.now().toString(36)}`, Date.now(), true);
589
607
  void app.persist();
590
608
  app.render();
591
609
  });
592
- if (saves.length && !argv.includes('--new')) await savePicker(app, { onPlay: (save) => { void begin(save); }, onNew: createFlow });
610
+ if (saves.length && !argv.includes('--new')) await reopenPicker();
593
611
  else createFlow();
594
612
  return new Promise((resolve) => { app.onQuit = resolve; });
595
613
  }
package/src/cli/tuiBg.mjs CHANGED
@@ -95,7 +95,7 @@ export function startBattleground(app, bgId) {
95
95
  app.say(yellow(`You enter ${def.name} for the ${TEAM[team]}. ${run.teamSize}v${run.teamSize}.`) + dim(' The gates open in 10 seconds.'));
96
96
  const view = bgView(app, bg);
97
97
  app.push(view);
98
- bg.timer = app.term.setInterval(() => { if (!bg.paused) { stepBg(app, bg, STEPS_PER_TICK * bg.speed); app.render(); } }, 100);
98
+ bg.timer = app.term.setInterval(() => { if (!bg.paused) { stepBg(app, bg, STEPS_PER_TICK * bg.speed); app.renderSoon(); } }, 100);
99
99
  return bg;
100
100
  }
101
101
 
@@ -1071,6 +1071,13 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1071
1071
  app.top().cursor = rowIndex((r, t) => /^\d+\s+empty$/.test(t.trim()) && !('b' in (r.data ?? {}))); term.press('return');
1072
1072
  app.top().cursor = rowIndex((r, t) => t.trim() === potion.name); term.press('return');
1073
1073
  check('a global bar slot takes a bag item', (g.state.globalActionBar?.items ?? []).includes(potion.entry));
1074
+ g.state.char.health = Math.max(1, Math.floor(g.state.char.health / 2));
1075
+ const potions = countItem(g.state.inventory, potion.entry);
1076
+ app.top().cursor = rowIndex((r, t) => /Fire the whole global bar/.test(t)); term.press('return');
1077
+ check('firing the global bar drinks the potion', countItem(g.state.inventory, potion.entry) === potions - 1);
1078
+ app.top().cursor = rowIndex((r, t) => t.includes(potion.name) && !('b' in (r.data ?? {}))); term.press('return');
1079
+ app.top().cursor = rowIndex((r, t) => t.trim() === 'Clear'); term.press('return');
1080
+ check('Clear empties the global slot', !(g.state.globalActionBar?.items ?? []).includes(potion.entry));
1074
1081
  term.press('escape'); home(); term.press('1');
1075
1082
  g.state.char.health = Math.max(1, Math.floor(g.state.char.health / 2));
1076
1083
  const mob = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
@@ -1268,6 +1275,17 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1268
1275
  home(); term.press(':'); for (const ch of 'settings') term.press(ch); term.press('return');
1269
1276
  pickRow(/^Travel pace/);
1270
1277
  check('Settings sets a real-time travel pace', app.prefs.get('travelPace') === 1);
1278
+ pickRow(/^Screen refresh/);
1279
+ check('Settings steps the screen refresh up from 100 ms', app.prefs.get('refreshMs') === 250);
1280
+ // On a real terminal a game loop's repaint waits out the refresh; a key's render paints at once and drops it.
1281
+ term.isTTY = true;
1282
+ app.render(); const painted = term.frames;
1283
+ app.renderSoon(); app.renderSoon();
1284
+ check('renderSoon holds a repaint inside the refresh window', term.frames === painted && app.paintTimer != null);
1285
+ app.render();
1286
+ check('render paints and cancels the held repaint', term.frames === painted + 1 && app.paintTimer == null);
1287
+ term.isTTY = false;
1288
+ app.prefs.set('refreshMs', 100);
1271
1289
  home();
1272
1290
  const here = g.state.char.zoneId;
1273
1291
  const dest = (ds.zone(here)?.neighbors ?? []).map((id) => ds.zone(id)).find((z) => z && g.previewTravel(z.id).ok);
@@ -1275,7 +1293,8 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1275
1293
  show('on the road');
1276
1294
  check('a trip at 1x takes time', !!g.state.char.journey && g.state.char.zoneId !== dest.id);
1277
1295
  check('the header counts it down', strip(frame()).split('\n')[0].includes(`→ ${dest.name}`));
1278
- check('the World tab opens on the journey card', app.tab === 0 && /On the road to/.test(strip(frame())));
1296
+ check('the Travel tab stays open, on the journey card', app.tab === 7 && /On the road to/.test(strip(frame())));
1297
+ term.press('1'); check('the World tab carries the card too', /On the road to/.test(strip(frame()))); term.press('8');
1279
1298
  app.top().cursor = rowIndex((r, t) => /journey options/.test(t)); term.press('return');
1280
1299
  pickRow(/^Travel at 4x/);
1281
1300
  check('the trip can be sped up', g.journeyStatus()?.timeScale === 4);
@@ -1685,6 +1704,39 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1685
1704
  await open();
1686
1705
  await choose(/^Keeper/);
1687
1706
  check('Enter on a save still plays it', played?.state?.char?.name === 'Keeper');
1707
+ // Locks: one character per process. A second repo stands in for a second terminal (same pid, so a
1708
+ // hand-written lock with a foreign live pid plays the other side; a dead pid is a stale lock).
1709
+ const { writeFileSync: writeLock, readFileSync: readLock } = await import('node:fs');
1710
+ const other = new FileSaveRepository(join(dir, 'saves'));
1711
+ check('lock() takes a free slot', repo10.lock('keeper').ok === true && existsSync(repo10.lockPath('keeper')));
1712
+ check('a lock we hold reads as free to us', repo10.lockedBy('keeper') === null);
1713
+ check('and as free to a second repo in the same process too', other.lockedBy('keeper') === null); // same pid: a reopen in-process is not a second terminal
1714
+ check('a different slot locks independently', other.lock('elsewhere').ok === true);
1715
+ writeLock(other.lockPath('ghost'), JSON.stringify({ pid: 2_000_000_000, at: 1 })); // no such process
1716
+ check('a stale lock (dead pid) is taken over', other.lock('ghost').ok === true && JSON.parse(readLock(other.lockPath('ghost'), 'utf8')).pid === process.pid);
1717
+ const livePid = process.ppid || process.pid + 1; // the parent shell is alive and is not us
1718
+ const foreign = JSON.stringify({ pid: livePid, at: Date.now() });
1719
+ writeLock(other.lockPath('taken'), foreign);
1720
+ const refused = other.lock('taken');
1721
+ check('a live lock from another process is refused', refused.ok === false && refused.pid === livePid && readLock(other.lockPath('taken'), 'utf8') === foreign);
1722
+ other.unlock('taken');
1723
+ check('unlock() never removes a lock we do not hold', existsSync(other.lockPath('taken')));
1724
+ repo10.unlock('keeper');
1725
+ check('unlock() releases ours', !existsSync(repo10.lockPath('keeper')));
1726
+ // The picker: a character held by another live process is marked and refuses to play or be managed.
1727
+ writeLock(repo10.lockPath('keeper'), foreign);
1728
+ played = null;
1729
+ await open(); show('save picker, keeper open elsewhere');
1730
+ const marked = () => app10.rowsOf(app10.top()).some((r) => /open in another terminal/.test(strip(r.text)));
1731
+ check('the picker marks a character open in another terminal', marked());
1732
+ await choose(/^Keeper.*open in another terminal/); // the imported copy is a Keeper too
1733
+ check('Enter refuses to play it', played === null && app10.log.map(strip).some((l) => /Keeper is open in another terminal/.test(l)));
1734
+ app10.top().cursor = at((r, t) => /^Keeper.*open in another terminal/.test(t)); term10.press('m'); await tick();
1735
+ check('m refuses to manage it', app10.top()?.title === 'Choose a character' && app10.log.map(strip).filter((l) => /Keeper is open in another terminal/.test(l)).length === 2);
1736
+ check('startGame on a held slot backs out', app10.startGame(newGameState(ds, human.id, 1, 'Keeper', 31, 0), 'keeper', Date.now(), true) === false && app10.g == null);
1737
+ writeLock(repo10.lockPath('keeper'), JSON.stringify({ pid: 2_000_000_000, at: 1 }));
1738
+ await open();
1739
+ check('a stale lock does not mark the character', !marked());
1688
1740
  term10.close?.();
1689
1741
  rmSync(dir, { recursive: true, force: true });
1690
1742
  if (exported) rmSync(exported, { force: true });
@@ -71,7 +71,7 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
71
71
  this.logEvents(events);
72
72
  if (!session.ended) this.onTick?.();
73
73
  if (session.ended) this.finish();
74
- app.render();
74
+ app.renderSoon();
75
75
  },
76
76
  logEvents(events) {
77
77
  app.meter?.ingest(events);
@@ -532,7 +532,7 @@ export function startAutopilot(app, steps = 200, farmQuery, quest) {
532
532
  }
533
533
  // The step line already tells the story; the engine's activity rows for it would repeat it.
534
534
  app.lastActivity = app.g.state.activity[0] ?? null;
535
- app.render();
535
+ app.renderSoon();
536
536
  return true;
537
537
  },
538
538
  stop() { if (this.timer) app.term.clearInterval(this.timer); this.timer = null; if (app.auto === this) app.auto = null; void app.persist(); app.render(); },
@@ -453,7 +453,7 @@ function floorView(app, f) {
453
453
  f.timer = a.term.setInterval(() => {
454
454
  if (a.top()?.id !== 'floor' || a.fight) return;
455
455
  for (let i = 0; i < f.speed; i++) if (stepFloor(a, f)) break;
456
- a.render();
456
+ a.renderSoon();
457
457
  }, TICK_MS);
458
458
  };
459
459
  const view = {
@@ -14,8 +14,12 @@ export async function savePicker(app, { onPlay, onNew }) {
14
14
  const live = new Set(saves.map((m) => m.slot));
15
15
  const orphans = [...new Map(deleted.filter((b) => !live.has(b.slot)).map((b) => [b.slot, b])).values()];
16
16
  const reopen = () => { app.stack = []; void savePicker(app, { onPlay, onNew }); };
17
+ // A character another terminal is playing right now is listed but cannot be opened or managed from here:
18
+ // two processes on one save would overwrite each other's progress on every action.
19
+ const busy = (m) => repo.lockedBy?.(m.slot) ?? null;
20
+ const refuse = (m, pid) => { app.say(red(`${m.name} is open in another terminal (pid ${pid}). Close it there first.`)); reopen(); }; // Enter popped the picker; put it back
17
21
  const options = saves.map((m) => ({
18
- label: `${bold(m.name)} ${dim(`L${m.level} ${className(m.cls)}`)}`,
22
+ label: `${bold(m.name)} ${dim(`L${m.level} ${className(m.cls)}`)}${busy(m) ? ` ${yellow('open in another terminal')}` : ''}`,
19
23
  detail: [bold(m.name), `Level ${m.level} ${className(m.cls)}`, dim(`last played ${when(m.updatedAt)}`), '', dim('Enter: play m: back up, restore, export, delete')],
20
24
  value: { kind: 'play', meta: m },
21
25
  }));
@@ -26,10 +30,16 @@ export async function savePicker(app, { onPlay, onNew }) {
26
30
  if (v.kind === 'new') return onNew();
27
31
  if (v.kind === 'import') return importFlow(app, reopen);
28
32
  if (v.kind === 'restore') { await repo.restoreBackup(v.backup.file); app.say(green(`${v.backup.name ?? v.backup.slot} is back.`)); return reopen(); }
33
+ const pid = busy(v.meta);
34
+ if (pid) return refuse(v.meta, pid);
29
35
  const save = await repo.load(v.meta.slot);
30
36
  if (save) onPlay(save);
31
37
  }, { hint: 'Enter: play' });
32
- view.keys = { m: { hint: 'manage', fn: (a, row) => { if (row?.data?.kind === 'play') manageMenu(a, row.data.meta, reopen); } } };
38
+ view.keys = { m: { hint: 'manage', fn: (a, row) => {
39
+ if (row?.data?.kind !== 'play') return;
40
+ const pid = busy(row.data.meta);
41
+ if (pid) refuse(row.data.meta, pid); else manageMenu(a, row.data.meta, reopen);
42
+ } } };
33
43
  app.render();
34
44
  return view;
35
45
  }
@@ -12,7 +12,14 @@ import { paceLabel, nextPace } from './tuiWorld.mjs';
12
12
  const H = (text) => ({ text, header: true });
13
13
  const QUALITY_NAME = ['Poor', 'Common', 'Uncommon', 'Rare', 'Epic'];
14
14
  const DE_STEPS = [null, 2, 3, 4]; // off, then the ceiling
15
+ const REFRESH_STEPS = [50, 100, 250, 500, 1000]; // ms between screen repaints while the game runs on its own
15
16
  const onOff = (v) => (v ? green('on') : dim('off'));
17
+
18
+ /** How often a running game loop repaints the screen, from the prefs file (100 ms, every engine tick, by default). */
19
+ export function refreshMs(prefs) {
20
+ const ms = prefs.get('refreshMs');
21
+ return REFRESH_STEPS.includes(ms) ? ms : 100;
22
+ }
16
23
  const box = (v) => (v ? green('✓') : dim('·'));
17
24
 
18
25
  /** The `createGame` options that back the every-character layers (and account macros) with the prefs file. */
@@ -50,6 +57,14 @@ export function settingsDialog(app) {
50
57
  const rows = [{ text: `Autopilot settings ${dim('resting, pulls, fighting, questing (:autopilot)')}`, act: (a2) => a2.push(autopilotSettingsDialog(a2)) }, H(''), H(bold('Loot'))];
51
58
  rows.push({ text: `Auto-loot ${onOff(autoLootOn(a))}`, detail: () => [bold('Auto-loot'), '', 'On: a corpse\'s drops and coin go straight into your bags.', 'Off: the loot window opens and you take what you want.'], act: (a2) => a2.prefs.set('autoLoot', !autoLootOn(a2)) });
52
59
 
60
+ const ms = refreshMs(a.prefs);
61
+ rows.push(H(''), H(bold('Screen')));
62
+ rows.push({
63
+ text: `Screen refresh ${ms === 100 ? dim('100 ms') : cyan(`${ms} ms`)}`,
64
+ detail: () => [bold('Screen refresh'), '', 'How often fights, the autopilot, dungeon walks and battlegrounds repaint the screen.', 'The game itself still ticks every 100 ms; this only changes how often you see it.', 'Slower is calmer and lighter on a slow terminal or a remote session; 50 ms is smoother at high speed.', 'Key presses always repaint at once.'],
65
+ act: (a2) => a2.prefs.set('refreshMs', REFRESH_STEPS[(REFRESH_STEPS.indexOf(ms) + 1) % REFRESH_STEPS.length]),
66
+ });
67
+
53
68
  const pace = a.prefs.get('travelPace') ?? 'instant';
54
69
  rows.push(H(''), H(bold('Travel')));
55
70
  rows.push({
@@ -26,7 +26,7 @@ export function simLabDialog(app, owner) {
26
26
  s.timer = a.term.setInterval(() => {
27
27
  s.run.step(STEP_MS);
28
28
  if (s.run.done) { stop(); a.say(green(`Dummy parse done: ${nf(s.report.dps.avg)} DPS.`)); }
29
- a.render();
29
+ a.renderSoon();
30
30
  }, 50);
31
31
  };
32
32
  const view = {
@@ -258,9 +258,16 @@ export function travelTo(app, dest) {
258
258
  if (!app.result(res)) return;
259
259
  app.vendor = null; app.cmds.setVendor(null);
260
260
  afterDeparture(app, () => `You travel to ${bold(dest.name)}. ${dim(`${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${moneyText(pv.totalCopper)}` : ''}`)}`);
261
+ landOnTab(app);
262
+ }
263
+
264
+ /** After a departure: stay on the Travel tab when the trip started there (you may want to go on), else open World. */
265
+ export function landOnTab(app) {
266
+ const travel = app.tabs.findIndex((t) => t.view?.id === 'travel');
267
+ const i = app.tab === travel && !app.stack.length ? travel : 0;
261
268
  app.stack = [];
262
- app.switchTab(0);
263
- app.tabs[0].view.cursor = 0; app.tabs[0].view.scroll = 0;
269
+ app.switchTab(i);
270
+ const v = app.tabs[i]?.view; if (v) { v.cursor = 0; v.scroll = 0; }
264
271
  }
265
272
 
266
273
  function questsView(app) {
@@ -861,7 +868,7 @@ function travelView(app) {
861
868
  id: 'travel', title: 'Travel', hint: 'Enter: go',
862
869
  rows(a) {
863
870
  const g = a.g, ds = a.ds, z = a.zone(), ch = a.ch;
864
- const rows = [{ text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }, { text: '', header: true }, { text: 'On foot', header: true }];
871
+ const rows = [...journeyRows(a), { text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }, { text: '', header: true }, { text: 'On foot', header: true }];
865
872
  for (const id of z?.neighbors ?? []) { const d = ds.zone(id); if (d) rows.push({ text: `${d.name} ${dim(`L${d.levelRange[0]}-${d.levelRange[1]}`)}`, detail: () => travelPreviewLines(a, d), act: () => travelTo(a, d) }); }
866
873
  const taxi = g.taxiOptions();
867
874
  if (taxi.length) {
@@ -874,12 +881,12 @@ function travelView(app) {
874
881
  rows.push({
875
882
  text: bind ? `Hearth to ${ds.zone(bind.zone)?.name ?? bind.zone}${bind.name ? dim(` (${bind.name})`) : ''}${cd > 0 ? red(` ${secondsText(cd)}`) : ''}` : dim('No home set: speak with an innkeeper.'),
876
883
  detail: () => [bold('Hearthstone'), bind ? `Bound at ${bind.name ?? ds.zone(bind.zone)?.name}` : 'Not bound', cd > 0 ? red(`Ready in ${secondsText(cd)}`) : green('Ready')],
877
- act: () => { const r = g.castHearth(); if (a.result(r)) { a.vendor = null; afterDeparture(a, () => `You hearth to ${a.zone()?.name}.`); a.switchTab(0); } },
884
+ act: () => { const r = g.castHearth(); if (a.result(r)) { a.vendor = null; afterDeparture(a, () => `You hearth to ${a.zone()?.name}.`); landOnTab(a); } },
878
885
  });
879
886
  const ports = g.classTravelOptions();
880
887
  if (ports.length) {
881
888
  rows.push({ text: '', header: true }, { text: 'Teleports', header: true });
882
- for (const p of ports) rows.push({ text: `${p.name} → ${ds.zone(p.toZone)?.name ?? p.toZone}${p.reagents.some((r) => r.have < r.count) ? red(' no reagent') : ''}`, act: () => { const r = g.castTeleport(p.spellId); if (a.result(r)) { afterDeparture(a, () => `You teleport to ${a.zone()?.name}.`); a.switchTab(0); } } });
889
+ for (const p of ports) rows.push({ text: `${p.name} → ${ds.zone(p.toZone)?.name ?? p.toZone}${p.reagents.some((r) => r.have < r.count) ? red(' no reagent') : ''}`, act: () => { const r = g.castTeleport(p.spellId); if (a.result(r)) { afterDeparture(a, () => `You teleport to ${a.zone()?.name}.`); landOnTab(a); } } });
883
890
  }
884
891
  rows.push(...portalRows(a));
885
892
  return rows;
@@ -56,7 +56,7 @@ export function pickPocketTag(app, creature) {
56
56
 
57
57
  // ---- journeys ------------------------------------------------------------------------------------------
58
58
  // With a real-time travel pace (Settings) a trip takes wall-clock time: the game loop calls journeyTick, the
59
- // header shows where you are headed, and the World tab opens on this card. Instant pace never gets here.
59
+ // header shows where you are headed, and the World and Travel tabs carry this card. Instant pace never gets here.
60
60
 
61
61
  const PACES = ['instant', 1, 2, 4];
62
62
  export const paceLabel = (p) => (p === 'instant' ? 'instant' : `${p}x real time`);
@@ -86,8 +86,7 @@ export function journeyTick(app) {
86
86
  if (!app.g.state.char.journey) {
87
87
  app.say(green(`You arrive in ${app.zone()?.name ?? app.ch.zoneId}.`));
88
88
  app.journeyWas = null;
89
- const world = app.tabs[0]?.view;
90
- if (world) { world.cursor = 0; world.scroll = 0; }
89
+ for (const v of [app.tabs[0]?.view, app.top()]) if (v) { v.cursor = 0; v.scroll = 0; }
91
90
  }
92
91
  }
93
92