moshcode 0.57.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -0
- package/bin/moshcode.mjs +2 -2
- package/examples/account.mosh +48 -0
- package/examples/aliases.mosh +56 -0
- package/examples/research-desk.mosh +49 -0
- package/package.json +1 -1
- package/src/auth.mjs +59 -64
- package/src/cli-schema.mjs +35 -0
- package/src/commands.mjs +305 -29
- package/src/cost-cli.mjs +232 -0
- package/src/cost-pricing.mjs +159 -0
- package/src/cost.mjs +634 -0
- package/src/games-breakout.mjs +64 -10
- package/src/games-paddle.mjs +128 -0
- package/src/games-pong.mjs +53 -4
- package/src/games.mjs +164 -12
- package/src/herd-cli.mjs +4 -0
- package/src/tui.mjs +38 -0
package/src/games-breakout.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// a wall. Without that you cannot dig a channel up the side of the wall, and
|
|
6
6
|
// digging a channel is the entire reason anybody still plays this.
|
|
7
7
|
import { advanceBall, drawnBall, drawnCell, snapBall } from "./games-draw.mjs";
|
|
8
|
+
import { glidePaddle, holdPaddle, paddleMotion, perSecond, pressPaddle, releasePaddle } from "./games-paddle.mjs";
|
|
8
9
|
import { acid, amber, bone, danger, rgb } from "./ui.mjs";
|
|
9
10
|
|
|
10
11
|
export const WIDTH = 40;
|
|
@@ -17,11 +18,11 @@ export const BRICK_TOP = 1;
|
|
|
17
18
|
|
|
18
19
|
export const PADDLE_W = 7;
|
|
19
20
|
export const PADDLE_ROW = HEIGHT - 1;
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
const PADDLE_STEP = 3;
|
|
21
|
+
// Three columns a press was enough to keep up with the ball and much too much
|
|
22
|
+
// to watch: at a terminal's repeat rate the paddle covered ninety columns a
|
|
23
|
+
// second in jumps you could count. It is now a press's worth of *travel* rather
|
|
24
|
+
// than a jump, paid out over the ticks that follow — see games-paddle.mjs.
|
|
25
|
+
const PADDLE_STEP = 3; // columns one press is worth
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* How often the wall is stepped. See the note in games-pong.mjs: the ball can
|
|
@@ -59,6 +60,23 @@ const MAX_VX = 1.4 * SCALE;
|
|
|
59
60
|
const MIN_VX = 0.15 * SCALE; // never let it go vertical and unsteerable
|
|
60
61
|
const LEVEL_UP = 1.12; // a multiplier on pace, so it does not scale
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* How fast the paddle runs while you hold an arrow, in columns per second.
|
|
65
|
+
*
|
|
66
|
+
* The fastest the ball ever travels sideways is `MAX_VX`, which is fifty-three
|
|
67
|
+
* columns a second. A paddle slower than that is one that a ball taken off the
|
|
68
|
+
* end can simply outrun, so this sits just above it: you can always still get
|
|
69
|
+
* there, and only just, which is the game. It crosses the board in a little
|
|
70
|
+
* over half a second.
|
|
71
|
+
*/
|
|
72
|
+
const PADDLE_RATE = perSecond(58, TICK_MS);
|
|
73
|
+
|
|
74
|
+
/** The board's edges, as far as the paddle is concerned. */
|
|
75
|
+
const PADDLE_GLIDE = { rate: PADDLE_RATE, step: PADDLE_STEP, lo: 0, hi: WIDTH - PADDLE_W };
|
|
76
|
+
|
|
77
|
+
/** Which way an arrow moves the paddle, and 0 for a key that is not one. */
|
|
78
|
+
const towards = (key) => (key === "left" ? -1 : key === "right" ? 1 : 0);
|
|
79
|
+
|
|
62
80
|
/** Top rows are worth more, which is what makes the ball worth risking. */
|
|
63
81
|
export const ROW_POINTS = [50, 40, 30, 20, 10];
|
|
64
82
|
const ROW_COLOR = [danger, amber, acid, rgb(90, 200, 250), rgb(190, 130, 255)];
|
|
@@ -97,6 +115,11 @@ export function launch(state) {
|
|
|
97
115
|
|
|
98
116
|
/** One tick. Exported so a test can clear a whole wall with no clock. */
|
|
99
117
|
export function step(state) {
|
|
118
|
+
// The paddle moves here, with the ball, rather than back in `onKey` — so it
|
|
119
|
+
// travels at a speed instead of jumping at the keyboard's repeat rate. It
|
|
120
|
+
// goes first so that a ball resting on it is put where it has just got to.
|
|
121
|
+
state.paddle = glidePaddle(state.motion, state.paddle, PADDLE_GLIDE);
|
|
122
|
+
|
|
100
123
|
if (state.stuck) {
|
|
101
124
|
// A ball that has not been launched rides the paddle, so moving before you
|
|
102
125
|
// serve aims the serve. It is carried rather than travelling, so it is put
|
|
@@ -175,11 +198,13 @@ export const BREAKOUT = {
|
|
|
175
198
|
blurb: "dig a channel up the side and let the ball do the rest",
|
|
176
199
|
keys: "← → paddle · space launch · q quit",
|
|
177
200
|
tickMs: TICK_MS,
|
|
201
|
+
heldKeys: true, // worth asking the terminal for key releases — see games.mjs
|
|
178
202
|
|
|
179
203
|
create({ rng = Math.random } = {}) {
|
|
180
204
|
const state = {
|
|
181
205
|
wall: buildWall(),
|
|
182
206
|
paddle: Math.floor((WIDTH - PADDLE_W) / 2),
|
|
207
|
+
motion: paddleMotion(),
|
|
183
208
|
score: 0,
|
|
184
209
|
lives: LIVES,
|
|
185
210
|
level: 1,
|
|
@@ -192,10 +217,36 @@ export const BREAKOUT = {
|
|
|
192
217
|
|
|
193
218
|
tick: step,
|
|
194
219
|
|
|
195
|
-
onKey(state, key) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
220
|
+
onKey(state, key, ctx) {
|
|
221
|
+
const dir = towards(key);
|
|
222
|
+
if (dir) {
|
|
223
|
+
// Told when the key comes back up, the paddle is simply put in gear and
|
|
224
|
+
// the tick does the rest — including for the auto-repeats that keep
|
|
225
|
+
// arriving while it is held, which must not be allowed to add a second
|
|
226
|
+
// helping of speed on top of the one the tick is already paying.
|
|
227
|
+
if (ctx?.heldKeys) { holdPaddle(state.motion, dir); return state; }
|
|
228
|
+
// Not told, it is paid for a press at a time. A press that finds it
|
|
229
|
+
// standing still is also spent immediately, so the frame this keypress
|
|
230
|
+
// draws already has it moving; a press that finds it already moving is
|
|
231
|
+
// not, because the tick is paying it out evenly and a second helping on
|
|
232
|
+
// top would be the very jolt this is here to remove.
|
|
233
|
+
const resting = state.motion.owed === 0;
|
|
234
|
+
pressPaddle(state.motion, dir, PADDLE_STEP);
|
|
235
|
+
if (resting) state.paddle = glidePaddle(state.motion, state.paddle, PADDLE_GLIDE);
|
|
236
|
+
// A ball waiting on the paddle is carried by it, and aiming the serve is
|
|
237
|
+
// the only thing you can do before you launch: it would look broken if it
|
|
238
|
+
// stayed behind until the next tick.
|
|
239
|
+
if (state.stuck) {
|
|
240
|
+
state.ball.x = state.paddle + PADDLE_W / 2;
|
|
241
|
+
snapBall(state.drawn, state.ball.x, state.ball.y);
|
|
242
|
+
}
|
|
243
|
+
} else if (key === "space" || key === "up" || key === "enter") launch(state);
|
|
244
|
+
return state;
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
onRelease(state, key) {
|
|
248
|
+
const dir = towards(key);
|
|
249
|
+
if (dir) releasePaddle(state.motion, dir);
|
|
199
250
|
return state;
|
|
200
251
|
},
|
|
201
252
|
|
|
@@ -223,7 +274,10 @@ export const BREAKOUT = {
|
|
|
223
274
|
}
|
|
224
275
|
}
|
|
225
276
|
|
|
226
|
-
|
|
277
|
+
// The paddle travels on fractions of a column and is drawn on whole ones,
|
|
278
|
+
// the same as the ball is.
|
|
279
|
+
const paddle = Math.round(state.paddle);
|
|
280
|
+
for (let i = 0; i < PADDLE_W; i++) put(paddle + i, PADDLE_ROW, bone("▀"));
|
|
227
281
|
// Drawn on half-rows and on its own even clock, so the ball steps the same
|
|
228
282
|
// distance down the wall as across it, and at a rate. See games-draw.mjs.
|
|
229
283
|
const ball = drawnCell(state.drawn);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Shared paddle motion, for the games where the thing you hold is a paddle.
|
|
2
|
+
//
|
|
3
|
+
// Everything else on a pong or breakout board already moves on the tick. The
|
|
4
|
+
// ball is stepped sixty times a second and then drawn on its own even clock
|
|
5
|
+
// (games-draw.mjs) precisely so that it reads as travelling rather than
|
|
6
|
+
// hopping. The paddle did not: it was moved by the keypress itself, one jump
|
|
7
|
+
// per key, which left the one object you are actually steering as the only
|
|
8
|
+
// thing on the board not moving at a rate.
|
|
9
|
+
//
|
|
10
|
+
// Two things fall out of that, and both of them read as a slow game rather than
|
|
11
|
+
// as a coarse control.
|
|
12
|
+
//
|
|
13
|
+
// A terminal has no key-up. What it has is auto-repeat: one press, then a gap
|
|
14
|
+
// of about half a second, then twenty-five or thirty a second for as long as
|
|
15
|
+
// you hold the key. A paddle moved by the key therefore sits still for that
|
|
16
|
+
// half second however hard you lean on the arrow — long enough for a pong ball
|
|
17
|
+
// to cross a third of the table — and then crosses the board in a blur. None of
|
|
18
|
+
// that is the paddle being slow. It is the paddle being on the keyboard's clock
|
|
19
|
+
// instead of the game's.
|
|
20
|
+
//
|
|
21
|
+
// And a step big enough to be worth those half-second gaps is a step you can
|
|
22
|
+
// watch land. Breakout moved three columns per key, which at thirty repeats a
|
|
23
|
+
// second is ninety columns a second delivered in visible three-column jumps:
|
|
24
|
+
// the paddle teleports, most of its own width, several times a second.
|
|
25
|
+
//
|
|
26
|
+
// So a key does not move the paddle here. It buys movement, and the tick spends
|
|
27
|
+
// it. A press adds `step` cells of debt; each tick pays out at most `rate` of
|
|
28
|
+
// it. Held, the debt is refilled faster than it can ever be paid, so the paddle
|
|
29
|
+
// runs at exactly `rate` — a constant speed, on the game's clock, for as long
|
|
30
|
+
// as the key is down, and identical whether the terminal repeats at fifteen a
|
|
31
|
+
// second or a hundred. Tapped, the debt is one `step` and the paddle glides
|
|
32
|
+
// that far and stops.
|
|
33
|
+
//
|
|
34
|
+
// The debt is capped at a single press, which is what makes letting go stop the
|
|
35
|
+
// paddle. Without the cap a held key would bank seconds of travel it had no
|
|
36
|
+
// time to spend, and the paddle would sail on long after the ball had gone by —
|
|
37
|
+
// the one thing worse than a paddle that will not start.
|
|
38
|
+
//
|
|
39
|
+
// The position stays a plain number on the game's state. Only the debt lives
|
|
40
|
+
// here, so a game keeps `state.you` or `state.paddle` as the coordinate it
|
|
41
|
+
// always was and a test can still put the paddle somewhere by assigning to it.
|
|
42
|
+
|
|
43
|
+
// All of that is what a terminal that will not say when a key comes back up
|
|
44
|
+
// leaves you with, and it is as good as that gets: the half-second before
|
|
45
|
+
// auto-repeat starts is a half second in which the terminal has told us
|
|
46
|
+
// nothing, and no paddle can be tuned out of a gap in its own input.
|
|
47
|
+
//
|
|
48
|
+
// A terminal that answers `HELD_KEYS.ask` (games.mjs) does say. There the
|
|
49
|
+
// paddle is not paced by presses at all — `holdPaddle` puts it in gear and it
|
|
50
|
+
// stays there, at exactly `rate`, until `releasePaddle` takes it out. No
|
|
51
|
+
// repeat delay to sit through, no overrun to bound, and the same `rate` and the
|
|
52
|
+
// same glide either way, so a game plays the same in both and simply answers
|
|
53
|
+
// sooner in one.
|
|
54
|
+
|
|
55
|
+
/** A paddle owing no movement. Games keep the position; this is the rest. */
|
|
56
|
+
export const paddleMotion = () => ({ owed: 0, held: 0 });
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A key held down, where the terminal will tell us when it is let go.
|
|
60
|
+
*
|
|
61
|
+
* The debt is refilled every tick for as long as this is in gear, so it can
|
|
62
|
+
* never run out and the paddle simply runs at `rate`.
|
|
63
|
+
*/
|
|
64
|
+
export function holdPaddle(motion, dir) {
|
|
65
|
+
motion.held = dir;
|
|
66
|
+
return motion;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* That key let go. `dir` is checked so that releasing the arrow you are no
|
|
71
|
+
* longer pressing cannot stop the one you are — rolling from one direction
|
|
72
|
+
* straight into the other sends the release for the first *after* the press for
|
|
73
|
+
* the second, and stopping on it would drop every reversal.
|
|
74
|
+
*/
|
|
75
|
+
export function releasePaddle(motion, dir) {
|
|
76
|
+
if (motion.held !== dir) return motion;
|
|
77
|
+
motion.held = 0;
|
|
78
|
+
motion.owed = 0;
|
|
79
|
+
return motion;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A press: `dir` is -1 or 1, `step` is how far one press is worth.
|
|
84
|
+
*
|
|
85
|
+
* Pressing the other way drops whatever is left of the old direction first, so
|
|
86
|
+
* a reversal starts the moment you ask for it rather than after the paddle has
|
|
87
|
+
* finished paying out the way it was already going. Getting that wrong costs a
|
|
88
|
+
* press, and a lost press on a paddle is a lost ball.
|
|
89
|
+
*/
|
|
90
|
+
export function pressPaddle(motion, dir, step) {
|
|
91
|
+
if (motion.owed * dir < 0) motion.owed = 0;
|
|
92
|
+
motion.owed = dir * Math.min(step, Math.abs(motion.owed) + step);
|
|
93
|
+
return motion;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Pay out this tick's share of the debt and return where the paddle now is.
|
|
98
|
+
*
|
|
99
|
+
* Called once per tick — and once more by a press that finds the paddle
|
|
100
|
+
* standing still, so that the frame drawn for that press already shows it
|
|
101
|
+
* moving. A tick away is only sixteen milliseconds, but starting on the frame
|
|
102
|
+
* you pressed is the difference between a control that answers and one that
|
|
103
|
+
* agrees to answer shortly. A press that finds the paddle already moving does
|
|
104
|
+
* not, because the tick is paying it out evenly and a second helping on top is
|
|
105
|
+
* exactly the jolt this is here to remove.
|
|
106
|
+
*/
|
|
107
|
+
export function glidePaddle(motion, at, { rate, step, lo, hi }) {
|
|
108
|
+
// A key that is being held owes a fresh press every tick, so the debt below
|
|
109
|
+
// never runs dry and the paddle just runs.
|
|
110
|
+
if (motion.held) motion.owed = motion.held * step;
|
|
111
|
+
if (!motion.owed) return at;
|
|
112
|
+
const move = Math.sign(motion.owed) * Math.min(rate, Math.abs(motion.owed));
|
|
113
|
+
motion.owed -= move;
|
|
114
|
+
const next = Math.min(hi, Math.max(lo, at + move));
|
|
115
|
+
// A paddle against the end of the board owes nothing. Left standing, the debt
|
|
116
|
+
// would sit there and then fire the instant you pressed the other way.
|
|
117
|
+
if (next === at) motion.owed = 0;
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Cells per tick, from a speed written in cells per second.
|
|
123
|
+
*
|
|
124
|
+
* Paddle speeds are a feel, and a feel is in seconds. Writing them per tick
|
|
125
|
+
* would silently re-tune every one of them the next time a game changed how
|
|
126
|
+
* often it steps.
|
|
127
|
+
*/
|
|
128
|
+
export const perSecond = (cells, tickMs) => (cells * tickMs) / 1000;
|
package/src/games-pong.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// not. That is the whole game, and it is why the angle off the paddle depends on
|
|
9
9
|
// where the ball hit it.
|
|
10
10
|
import { advanceBall, drawnBall, drawnCell, snapBall } from "./games-draw.mjs";
|
|
11
|
+
import { glidePaddle, holdPaddle, paddleMotion, perSecond, pressPaddle, releasePaddle } from "./games-paddle.mjs";
|
|
11
12
|
import { acid, bone, danger, dim } from "./ui.mjs";
|
|
12
13
|
|
|
13
14
|
export const WIDTH = 44;
|
|
@@ -64,10 +65,34 @@ const THEM_SPEED = 0.3 * SCALE; // slow enough that a ball into the corner bea
|
|
|
64
65
|
const FLAT = 0.08 * SCALE; // below this a rally has gone flat
|
|
65
66
|
const NUDGE = 0.12 * SCALE; // and this is the angle it is put back at
|
|
66
67
|
const MAX_VY = 0.5 * SCALE;
|
|
67
|
-
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Your paddle, which moves on this game's clock and not on the keyboard's.
|
|
71
|
+
*
|
|
72
|
+
* `YOU_RATE` is the speed the paddle actually travels at while you hold an
|
|
73
|
+
* arrow, in rows per second, so it says something about the game rather than
|
|
74
|
+
* about the rate the game happens to tick at. Twenty-one rows a second crosses
|
|
75
|
+
* the table's twelve in a little over half a second — comfortably faster than
|
|
76
|
+
* the ball falls, which is what makes a ball put into the corner a shot you
|
|
77
|
+
* lost rather than one you were never allowed to reach. The machine is left at
|
|
78
|
+
* ten rows a second and so is still beatable exactly where it always was.
|
|
79
|
+
*
|
|
80
|
+
* `YOU_STEP` is what one press is worth, and therefore also how far the paddle
|
|
81
|
+
* will still glide after you let go: nudging without holding moves a shade over
|
|
82
|
+
* a row, and a released paddle is done within a tenth of a second. See
|
|
83
|
+
* games-paddle.mjs for why a press buys movement instead of performing it.
|
|
84
|
+
*/
|
|
85
|
+
const YOU_RATE = perSecond(21, TICK_MS);
|
|
86
|
+
const YOU_STEP = 1.2; // rows one press is worth
|
|
68
87
|
|
|
69
88
|
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
70
89
|
|
|
90
|
+
/** The table's ends, as far as your paddle is concerned. */
|
|
91
|
+
const YOU_GLIDE = { rate: YOU_RATE, step: YOU_STEP, lo: 0, hi: HEIGHT - PADDLE };
|
|
92
|
+
|
|
93
|
+
/** Which way an arrow moves your paddle, and 0 for a key that is not one. */
|
|
94
|
+
const towards = (key) => (key === "up" ? -1 : key === "down" ? 1 : 0);
|
|
95
|
+
|
|
71
96
|
/** A ball in the middle, heading at whoever just lost the point. */
|
|
72
97
|
export function serve(state, toward) {
|
|
73
98
|
state.ball = {
|
|
@@ -103,6 +128,9 @@ function returned(ball, top, dir) {
|
|
|
103
128
|
|
|
104
129
|
/** One tick of rally. Exported so a test can play a whole match with no clock. */
|
|
105
130
|
export function step(state) {
|
|
131
|
+
// Your paddle moves here, with everything else, rather than back in `onKey`.
|
|
132
|
+
state.you = glidePaddle(state.motion, state.you, YOU_GLIDE);
|
|
133
|
+
|
|
106
134
|
const ball = state.ball;
|
|
107
135
|
ball.x += ball.vx;
|
|
108
136
|
ball.y += ball.vy;
|
|
@@ -151,10 +179,12 @@ export const PONG = {
|
|
|
151
179
|
blurb: "first to seven, and the angle is all in where you hit it",
|
|
152
180
|
keys: "↑ ↓ move · q quit",
|
|
153
181
|
tickMs: TICK_MS,
|
|
182
|
+
heldKeys: true, // worth asking the terminal for key releases — see games.mjs
|
|
154
183
|
|
|
155
184
|
create({ rng = Math.random } = {}) {
|
|
156
185
|
const state = {
|
|
157
186
|
you: (HEIGHT - PADDLE) / 2,
|
|
187
|
+
motion: paddleMotion(),
|
|
158
188
|
them: (HEIGHT - PADDLE) / 2,
|
|
159
189
|
yours: 0,
|
|
160
190
|
theirs: 0,
|
|
@@ -166,9 +196,28 @@ export const PONG = {
|
|
|
166
196
|
|
|
167
197
|
tick: step,
|
|
168
198
|
|
|
169
|
-
onKey(state, key) {
|
|
170
|
-
|
|
171
|
-
if (
|
|
199
|
+
onKey(state, key, ctx) {
|
|
200
|
+
const dir = towards(key);
|
|
201
|
+
if (!dir) return state;
|
|
202
|
+
// Told when the key comes back up, the paddle is simply put in gear and the
|
|
203
|
+
// tick does the rest — including for the auto-repeats that keep arriving
|
|
204
|
+
// while it is held, which must not be allowed to add a second helping of
|
|
205
|
+
// speed on top of the one the tick is already paying. See games-paddle.mjs.
|
|
206
|
+
if (ctx?.heldKeys) { holdPaddle(state.motion, dir); return state; }
|
|
207
|
+
// Not told, the paddle is paid for a press at a time. A press that finds it
|
|
208
|
+
// standing still is also spent immediately, so the frame this keypress
|
|
209
|
+
// draws is already one it has moved in; a press that finds it already
|
|
210
|
+
// moving is not, because the tick is paying it out evenly and a second
|
|
211
|
+
// helping on top would be the very jolt this is here to remove.
|
|
212
|
+
const resting = state.motion.owed === 0;
|
|
213
|
+
pressPaddle(state.motion, dir, YOU_STEP);
|
|
214
|
+
if (resting) state.you = glidePaddle(state.motion, state.you, YOU_GLIDE);
|
|
215
|
+
return state;
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
onRelease(state, key) {
|
|
219
|
+
const dir = towards(key);
|
|
220
|
+
if (dir) releasePaddle(state.motion, dir);
|
|
172
221
|
return state;
|
|
173
222
|
},
|
|
174
223
|
|
package/src/games.mjs
CHANGED
|
@@ -45,8 +45,14 @@ import { OUTRUN } from "./games-outrun.mjs";
|
|
|
45
45
|
* @property {number|Function} [tickMs] real-time games only — a number, or (state) => number
|
|
46
46
|
* @property {boolean} [vim] false when a game wants h/j/k/l as letters, not arrows
|
|
47
47
|
* @property {boolean} [restartable] false when `r` is only a restart once the game is over
|
|
48
|
+
* @property {boolean} [heldKeys] true for a game where a key is held rather than
|
|
49
|
+
* tapped, which is worth asking the terminal for key releases for. See
|
|
50
|
+
* `HELD_KEYS`; `ctx.heldKeys` then says whether it agreed.
|
|
48
51
|
* @property {Function} create ({ rng }) => state
|
|
49
|
-
* @property {Function} onKey (state, key, { rng }) => state
|
|
52
|
+
* @property {Function} onKey (state, key, { rng, heldKeys }) => state
|
|
53
|
+
* @property {Function} [onRelease] (state, key, ctx) => state — that key let go,
|
|
54
|
+
* and only in a terminal that reports it. Never the first thing a game hears
|
|
55
|
+
* about a key, so it is only ever a reason to stop.
|
|
50
56
|
* @property {Function} [tick] (state, { rng }) => state
|
|
51
57
|
* @property {Function} render (state) => string[] the board, already coloured
|
|
52
58
|
* @property {Function} status (state) => string right of the title
|
|
@@ -112,6 +118,48 @@ export function frame({ title = "", status = "", rows = [], keys = "" } = {}) {
|
|
|
112
118
|
|
|
113
119
|
/* --------------------------------------------------------------------- keys */
|
|
114
120
|
|
|
121
|
+
const VIM_KEYS = { h: "left", j: "down", k: "up", l: "right" };
|
|
122
|
+
|
|
123
|
+
/** Codepoints that are a named key rather than a character. */
|
|
124
|
+
const NAMED_CODES = { 9: "tab", 13: "enter", 27: "escape", 32: "space", 127: "backspace" };
|
|
125
|
+
|
|
126
|
+
/** A CSI sequence ends at the first byte in this range. */
|
|
127
|
+
const isFinal = (c) => c >= "\x40" && c <= "\x7e";
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* One CSI sequence → a key name, or null for one the arcade does not use.
|
|
131
|
+
*
|
|
132
|
+
* Two shapes arrive here. Arrows are `CSI A`..`CSI D`, as they have been
|
|
133
|
+
* forever. Everything from the keyboard protocol (see `HELD_KEYS` below) is
|
|
134
|
+
* `CSI code ; modifiers : event u` — a codepoint, which modifier keys were
|
|
135
|
+
* down, and whether the key was pressed, repeated or released. Both carry the
|
|
136
|
+
* event in the same place, so both are read the same way.
|
|
137
|
+
*/
|
|
138
|
+
function csiKey(params, final, vim) {
|
|
139
|
+
const fields = params.split(";");
|
|
140
|
+
const key = (fields[0] ?? "").split(":");
|
|
141
|
+
const mod = (fields[1] ?? "").split(":");
|
|
142
|
+
// Modifiers are sent as a bitmask plus one, so that an absent field and "no
|
|
143
|
+
// modifiers" are the same thing.
|
|
144
|
+
const modifiers = mod[0] ? Number.parseInt(mod[0], 10) - 1 : 0;
|
|
145
|
+
const event = mod[1] ? Number.parseInt(mod[1], 10) : 1;
|
|
146
|
+
|
|
147
|
+
let name = { A: "up", B: "down", C: "right", D: "left" }[final] ?? null;
|
|
148
|
+
if (!name && final === "u") {
|
|
149
|
+
const code = Number.parseInt(key[0], 10);
|
|
150
|
+
if (!Number.isFinite(code)) return null;
|
|
151
|
+
name = NAMED_CODES[code]
|
|
152
|
+
?? (code >= 32 && code <= 126 ? String.fromCodePoint(code).toLowerCase() : null);
|
|
153
|
+
}
|
|
154
|
+
if (!name) return null;
|
|
155
|
+
// Ctrl-c and ctrl-d are a quit however they arrive.
|
|
156
|
+
if (modifiers & 4) return name === "c" || name === "d" ? "quit" : null;
|
|
157
|
+
if (vim && VIM_KEYS[name]) name = VIM_KEYS[name];
|
|
158
|
+
// 1 is a press, 2 a repeat — both are the key being down, which is all a game
|
|
159
|
+
// that does not care about releases ever sees. 3 is the key coming back up.
|
|
160
|
+
return event === 3 ? `release:${name}` : name;
|
|
161
|
+
}
|
|
162
|
+
|
|
115
163
|
/**
|
|
116
164
|
* Raw terminal bytes → key names the games understand.
|
|
117
165
|
*
|
|
@@ -119,6 +167,10 @@ export function frame({ title = "", status = "", rows = [], keys = "" } = {}) {
|
|
|
119
167
|
* hold several keypresses (hold an arrow key down and they arrive in batches),
|
|
120
168
|
* which is why this returns a list.
|
|
121
169
|
*
|
|
170
|
+
* A key coming back up arrives as `release:up`, and only in a terminal that was
|
|
171
|
+
* asked for them and agreed — see `HELD_KEYS`. Games that do not implement
|
|
172
|
+
* `onRelease` never see one.
|
|
173
|
+
*
|
|
122
174
|
* `vim: false` is for the games that read letters — hangman cannot ask for a
|
|
123
175
|
* word with an `h` in it while `h` means left, and blackjack wants `h` to be
|
|
124
176
|
* hit. Arrows are unaffected either way; they arrive as escape sequences.
|
|
@@ -129,12 +181,22 @@ export function decodeKeys(chunk, { vim = true } = {}) {
|
|
|
129
181
|
for (let i = 0; i < input.length; i++) {
|
|
130
182
|
const c = input[i];
|
|
131
183
|
if (c === "\x1b") {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
184
|
+
const intro = input[i + 1];
|
|
185
|
+
if (intro === "[" || intro === "O") {
|
|
186
|
+
// Run to the end of the sequence and read it as a whole. Skipping a
|
|
187
|
+
// fixed two bytes instead — which is what this used to do — leaves the
|
|
188
|
+
// tail of anything longer than an arrow to be read as if it had been
|
|
189
|
+
// typed, so a mouse report or a terminal's answer to a question lands
|
|
190
|
+
// in the game as a fistful of letters.
|
|
191
|
+
let j = i + 2;
|
|
192
|
+
while (j < input.length && !isFinal(input[j])) j++;
|
|
193
|
+
// A sequence split across two chunks is dropped rather than half-read.
|
|
194
|
+
if (j >= input.length) break;
|
|
195
|
+
const key = csiKey(input.slice(i + 2, j), input[j], vim);
|
|
196
|
+
if (key) keys.push(key);
|
|
197
|
+
i = j;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
138
200
|
keys.push("escape");
|
|
139
201
|
continue;
|
|
140
202
|
}
|
|
@@ -145,7 +207,7 @@ export function decodeKeys(chunk, { vim = true } = {}) {
|
|
|
145
207
|
if (c === "\t") { keys.push("tab"); continue; }
|
|
146
208
|
// vim keys, everywhere, for free — every game that reads arrows gets them
|
|
147
209
|
// without knowing about them. A game that reads letters opts out.
|
|
148
|
-
const vimKey = vim ?
|
|
210
|
+
const vimKey = vim ? VIM_KEYS[c] : null;
|
|
149
211
|
if (vimKey) { keys.push(vimKey); continue; }
|
|
150
212
|
if (c >= " " && c <= "~") keys.push(c.toLowerCase());
|
|
151
213
|
}
|
|
@@ -197,6 +259,36 @@ const ESC = {
|
|
|
197
259
|
eraseLine: "\x1b[K",
|
|
198
260
|
};
|
|
199
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Asking the terminal to say when a key comes back up.
|
|
264
|
+
*
|
|
265
|
+
* A terminal does not report key releases. What it reports is auto-repeat: the
|
|
266
|
+
* press, then nothing for about half a second, then thirty a second until you
|
|
267
|
+
* let go. For a game where you hold a direction that half second is the whole
|
|
268
|
+
* problem — a pong paddle cannot start moving until the ball is a third of the
|
|
269
|
+
* way down the table, and no amount of making the paddle faster fixes it,
|
|
270
|
+
* because there is nothing to be fast about yet.
|
|
271
|
+
*
|
|
272
|
+
* The keyboard protocol fixes it properly: `HELD_KEYS.on` asks for keys to be
|
|
273
|
+
* reported as press, repeat and release, so a paddle can move for exactly as
|
|
274
|
+
* long as the key is down and stop the instant it is not.
|
|
275
|
+
*
|
|
276
|
+
* Not every terminal implements it, so it is asked for rather than assumed:
|
|
277
|
+
* `ask` is a question a terminal that supports it answers and one that does not
|
|
278
|
+
* ignores, and only an answer turns it on. That is what keeps this free of
|
|
279
|
+
* risk — a terminal that stays quiet is sent nothing and behaves exactly as it
|
|
280
|
+
* did before, and one that answers has told us it understands the sequences it
|
|
281
|
+
* is about to be sent.
|
|
282
|
+
*/
|
|
283
|
+
const HELD_KEYS = {
|
|
284
|
+
ask: "\x1b[?u",
|
|
285
|
+
answer: /\x1b\[\?[\d;]*u/,
|
|
286
|
+
on: "\x1b[>3u", // disambiguate escape codes, and report press/repeat/release
|
|
287
|
+
off: "\x1b[<u", // put back whatever the terminal had before
|
|
288
|
+
/** How long a terminal gets to answer before we take the silence as a no. */
|
|
289
|
+
waitMs: 60,
|
|
290
|
+
};
|
|
291
|
+
|
|
200
292
|
/**
|
|
201
293
|
* How many ticks a late clock is allowed to make up in one go.
|
|
202
294
|
*
|
|
@@ -332,11 +424,44 @@ export async function runGame(game, deps = {}) {
|
|
|
332
424
|
arm();
|
|
333
425
|
}
|
|
334
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Ask the terminal whether it will report key releases, and wait briefly.
|
|
429
|
+
*
|
|
430
|
+
* Only for the games that can use them, and only on a real terminal — a test
|
|
431
|
+
* hands in a stream that will never answer, and waiting on it would put a
|
|
432
|
+
* timeout in front of every game in the suite.
|
|
433
|
+
*
|
|
434
|
+
* Anything the player typed while we were waiting is handed back rather than
|
|
435
|
+
* eaten, so that a key pressed the instant a game starts still counts.
|
|
436
|
+
*/
|
|
437
|
+
const askForReleases = () => {
|
|
438
|
+
if (!game.heldKeys || !input.isTTY) return Promise.resolve({ held: false, typed: "" });
|
|
439
|
+
return new Promise((res) => {
|
|
440
|
+
let seen = "";
|
|
441
|
+
let settled = false;
|
|
442
|
+
const finish = (held) => {
|
|
443
|
+
if (settled) return;
|
|
444
|
+
settled = true;
|
|
445
|
+
clearTimer(waiting);
|
|
446
|
+
input.off?.("data", listen);
|
|
447
|
+
res({ held, typed: seen.replace(HELD_KEYS.answer, "") });
|
|
448
|
+
};
|
|
449
|
+
const listen = (chunk) => {
|
|
450
|
+
seen += String(chunk);
|
|
451
|
+
if (HELD_KEYS.answer.test(seen)) finish(true);
|
|
452
|
+
};
|
|
453
|
+
const waiting = setTimer(() => finish(false), HELD_KEYS.waitMs);
|
|
454
|
+
input.on?.("data", listen);
|
|
455
|
+
output.write(HELD_KEYS.ask);
|
|
456
|
+
});
|
|
457
|
+
};
|
|
458
|
+
|
|
335
459
|
const wasRaw = Boolean(input.isRaw);
|
|
336
460
|
const restore = () => {
|
|
337
461
|
if (closed) return;
|
|
338
462
|
closed = true;
|
|
339
463
|
stop();
|
|
464
|
+
if (ctx.heldKeys) output.write(HELD_KEYS.off);
|
|
340
465
|
output.write(ESC.showCursor);
|
|
341
466
|
try { input.setRawMode?.(wasRaw); } catch { /* already gone */ }
|
|
342
467
|
input.off?.("data", onData);
|
|
@@ -345,18 +470,34 @@ export async function runGame(game, deps = {}) {
|
|
|
345
470
|
const onSignal = () => { restore(); process.exit(130); };
|
|
346
471
|
|
|
347
472
|
function onData(chunk) {
|
|
473
|
+
// One chunk can carry several keypresses — holding an arrow down delivers
|
|
474
|
+
// them in batches — and every one of them is applied before anything is
|
|
475
|
+
// drawn. Drawing per key instead paints frames nobody can see: the batch is
|
|
476
|
+
// consumed in the same millisecond, so all but the last are overwritten
|
|
477
|
+
// before the terminal has finished with them, having cost a full frame
|
|
478
|
+
// build and a write each on the way past.
|
|
479
|
+
let moved = false;
|
|
348
480
|
for (const key of decodeKeys(chunk, { vim: game.vim !== false })) {
|
|
481
|
+
// A key coming back up is not a move. It only ever tells a game to stop
|
|
482
|
+
// doing something, so it cannot start, restart or end one.
|
|
483
|
+
if (key.startsWith("release:")) {
|
|
484
|
+
if (game.onRelease && !state.over) {
|
|
485
|
+
state = game.onRelease(state, key.slice(8), ctx) || state;
|
|
486
|
+
moved = true;
|
|
487
|
+
}
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
349
490
|
if (key === "quit" || key === "q" || key === "escape") { restore(); resolve(); return; }
|
|
350
491
|
if (key === "r" && (state.over || game.restartable !== false)) {
|
|
351
492
|
state = game.create(ctx);
|
|
352
493
|
dueAt = null; // a new game starts its clock from now, not from the old one
|
|
353
|
-
|
|
494
|
+
moved = true;
|
|
354
495
|
arm();
|
|
355
496
|
continue;
|
|
356
497
|
}
|
|
357
498
|
if (state.over) continue; // a finished board takes r and q, nothing else
|
|
358
499
|
state = game.onKey(state, key, ctx) || state;
|
|
359
|
-
|
|
500
|
+
moved = true;
|
|
360
501
|
// A key can end a real-time game — a hard drop into the ceiling — so the
|
|
361
502
|
// clock is stopped when that happens. Otherwise see `nudge`.
|
|
362
503
|
if (game.tickMs) {
|
|
@@ -364,6 +505,7 @@ export async function runGame(game, deps = {}) {
|
|
|
364
505
|
else nudge();
|
|
365
506
|
}
|
|
366
507
|
}
|
|
508
|
+
if (moved) draw();
|
|
367
509
|
}
|
|
368
510
|
|
|
369
511
|
let resolve;
|
|
@@ -373,12 +515,22 @@ export async function runGame(game, deps = {}) {
|
|
|
373
515
|
try { input.setRawMode?.(true); } catch { /* not a tty */ }
|
|
374
516
|
input.setEncoding?.("utf8");
|
|
375
517
|
input.resume?.();
|
|
376
|
-
input.on?.("data", onData);
|
|
377
518
|
process.on("SIGINT", onSignal);
|
|
378
519
|
process.on("SIGTERM", onSignal);
|
|
379
520
|
|
|
521
|
+
// The board goes up before the terminal is asked anything, so that a game
|
|
522
|
+
// still starts instantly on a terminal that takes the full wait to not answer.
|
|
380
523
|
draw();
|
|
381
|
-
|
|
524
|
+
const { held, typed } = await askForReleases();
|
|
525
|
+
// Quitting while the terminal was being asked leaves nothing to set up, and
|
|
526
|
+
// turning the protocol on now would leave it on with no game to turn it off.
|
|
527
|
+
if (!closed) {
|
|
528
|
+
ctx.heldKeys = held;
|
|
529
|
+
if (held) output.write(HELD_KEYS.on);
|
|
530
|
+
input.on?.("data", onData);
|
|
531
|
+
if (typed) onData(typed);
|
|
532
|
+
arm();
|
|
533
|
+
}
|
|
382
534
|
await done;
|
|
383
535
|
restore();
|
|
384
536
|
process.off("SIGINT", onSignal);
|
package/src/herd-cli.mjs
CHANGED
|
@@ -754,6 +754,10 @@ const VERBS = {
|
|
|
754
754
|
bar: async (argv, options) => (await import("./herd-bar.mjs")).herdBar(options),
|
|
755
755
|
tile: async (argv, options) => (await import("./herd-tile.mjs")).herdTile(argv, options),
|
|
756
756
|
untile: async (argv, options) => (await import("./herd-tile.mjs")).herdUntile(argv, options),
|
|
757
|
+
// Lazy for the same reason as the UI verbs: reading every engine's session
|
|
758
|
+
// log is work nobody asked for until they type `cost`, and cost-cli imports
|
|
759
|
+
// roster() back out of this file.
|
|
760
|
+
cost: async (argv, options) => (await import("./cost-cli.mjs")).costCommand(argv, options),
|
|
757
761
|
ps: herdPs, list: herdPs, status: herdStatus,
|
|
758
762
|
start: herdStart, run: herdRun, shell: herdShell,
|
|
759
763
|
attach: herdAttach, kill: herdKill, prune: herdPrune,
|