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 +11 -1
- package/src/cli/fileRepository.mjs +46 -1
- package/src/cli/repl.mjs +4 -0
- package/src/cli/tui.mjs +21 -3
- package/src/cli/tuiBg.mjs +1 -1
- package/src/cli/tuiCheck.mjs +53 -1
- package/src/cli/tuiFight.mjs +2 -2
- package/src/cli/tuiFloor.mjs +1 -1
- package/src/cli/tuiSaves.mjs +12 -2
- package/src/cli/tuiSettings.mjs +15 -0
- package/src/cli/tuiSimLab.mjs +1 -1
- package/src/cli/tuiViews.mjs +12 -5
- package/src/cli/tuiWorld.mjs +2 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "azeroth-shell",
|
|
3
|
-
"version": "0.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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
package/src/cli/tuiCheck.mjs
CHANGED
|
@@ -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
|
|
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 });
|
package/src/cli/tuiFight.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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(); },
|
package/src/cli/tuiFloor.mjs
CHANGED
|
@@ -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.
|
|
456
|
+
a.renderSoon();
|
|
457
457
|
}, TICK_MS);
|
|
458
458
|
};
|
|
459
459
|
const view = {
|
package/src/cli/tuiSaves.mjs
CHANGED
|
@@ -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) => {
|
|
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
|
}
|
package/src/cli/tuiSettings.mjs
CHANGED
|
@@ -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({
|
package/src/cli/tuiSimLab.mjs
CHANGED
|
@@ -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.
|
|
29
|
+
a.renderSoon();
|
|
30
30
|
}, 50);
|
|
31
31
|
};
|
|
32
32
|
const view = {
|
package/src/cli/tuiViews.mjs
CHANGED
|
@@ -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(
|
|
263
|
-
app.tabs[
|
|
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
|
|
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
|
|
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;
|
package/src/cli/tuiWorld.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|