pi-extensions 0.1.9

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.
Files changed (135) hide show
  1. package/.ralph/import-cc-codex.md +31 -0
  2. package/.ralph/import-cc-codex.state.json +14 -0
  3. package/.ralph/mario-not-impl.md +69 -0
  4. package/.ralph/mario-not-impl.state.json +14 -0
  5. package/.ralph/mario-not-spec.md +163 -0
  6. package/.ralph/mario-not-spec.state.json +14 -0
  7. package/LICENSE +21 -0
  8. package/README.md +65 -0
  9. package/RELEASING.md +34 -0
  10. package/agent-guidance/CHANGELOG.md +4 -0
  11. package/agent-guidance/README.md +102 -0
  12. package/agent-guidance/agent-guidance.ts +147 -0
  13. package/agent-guidance/package.json +22 -0
  14. package/agent-guidance/setup.sh +75 -0
  15. package/agent-guidance/templates/CLAUDE.md +5 -0
  16. package/agent-guidance/templates/CODEX.md +92 -0
  17. package/agent-guidance/templates/GEMINI.md +5 -0
  18. package/arcade/CHANGELOG.md +4 -0
  19. package/arcade/README.md +85 -0
  20. package/arcade/assets/picman.png +0 -0
  21. package/arcade/assets/ping.png +0 -0
  22. package/arcade/assets/spice-invaders.png +0 -0
  23. package/arcade/assets/tetris.png +0 -0
  24. package/arcade/mario-not/README.md +30 -0
  25. package/arcade/mario-not/boss.js +103 -0
  26. package/arcade/mario-not/camera.js +59 -0
  27. package/arcade/mario-not/collision.js +91 -0
  28. package/arcade/mario-not/colors.js +36 -0
  29. package/arcade/mario-not/constants.js +97 -0
  30. package/arcade/mario-not/core.js +39 -0
  31. package/arcade/mario-not/death.js +77 -0
  32. package/arcade/mario-not/effects.js +84 -0
  33. package/arcade/mario-not/enemies.js +31 -0
  34. package/arcade/mario-not/engine.js +171 -0
  35. package/arcade/mario-not/fireballs.js +98 -0
  36. package/arcade/mario-not/items.js +24 -0
  37. package/arcade/mario-not/levels.js +403 -0
  38. package/arcade/mario-not/logic.js +104 -0
  39. package/arcade/mario-not/mario-not.ts +297 -0
  40. package/arcade/mario-not/player.js +244 -0
  41. package/arcade/mario-not/render.js +257 -0
  42. package/arcade/mario-not/spec.md +548 -0
  43. package/arcade/mario-not/state.js +246 -0
  44. package/arcade/mario-not/tests/e2e.test.js +855 -0
  45. package/arcade/mario-not/tests/engine.test.js +888 -0
  46. package/arcade/mario-not/tests/fixtures/story0-frame.txt +4 -0
  47. package/arcade/mario-not/tests/fixtures/story1-camera.txt +4 -0
  48. package/arcade/mario-not/tests/fixtures/story1-glyphs.txt +4 -0
  49. package/arcade/mario-not/tests/fixtures/story10-item.txt +4 -0
  50. package/arcade/mario-not/tests/fixtures/story11-hazards.txt +4 -0
  51. package/arcade/mario-not/tests/fixtures/story12-used-block.txt +4 -0
  52. package/arcade/mario-not/tests/fixtures/story13-pipes.txt +4 -0
  53. package/arcade/mario-not/tests/fixtures/story14-goal.txt +4 -0
  54. package/arcade/mario-not/tests/fixtures/story15-hud-narrow.txt +2 -0
  55. package/arcade/mario-not/tests/fixtures/story16-unknown-tile.txt +4 -0
  56. package/arcade/mario-not/tests/fixtures/story17-mix.txt +4 -0
  57. package/arcade/mario-not/tests/fixtures/story18-hud-score.txt +2 -0
  58. package/arcade/mario-not/tests/fixtures/story19-cue.txt +4 -0
  59. package/arcade/mario-not/tests/fixtures/story2-enemy.txt +4 -0
  60. package/arcade/mario-not/tests/fixtures/story20-camera-offset.txt +4 -0
  61. package/arcade/mario-not/tests/fixtures/story21-hud-zero.txt +2 -0
  62. package/arcade/mario-not/tests/fixtures/story22-big-viewport.txt +4 -0
  63. package/arcade/mario-not/tests/fixtures/story23-camera-negative.txt +4 -0
  64. package/arcade/mario-not/tests/fixtures/story24-camera-width.txt +4 -0
  65. package/arcade/mario-not/tests/fixtures/story25-camera-positive.txt +4 -0
  66. package/arcade/mario-not/tests/fixtures/story26-hud-lives.txt +2 -0
  67. package/arcade/mario-not/tests/fixtures/story27-hud-coins.txt +2 -0
  68. package/arcade/mario-not/tests/fixtures/story28-item-viewport.txt +4 -0
  69. package/arcade/mario-not/tests/fixtures/story29-enemy-viewport.txt +4 -0
  70. package/arcade/mario-not/tests/fixtures/story3-hud.txt +2 -0
  71. package/arcade/mario-not/tests/fixtures/story30-hud-score.txt +2 -0
  72. package/arcade/mario-not/tests/fixtures/story31-particles-viewport.txt +4 -0
  73. package/arcade/mario-not/tests/fixtures/story32-paused-frame.txt +4 -0
  74. package/arcade/mario-not/tests/fixtures/story4-big.txt +4 -0
  75. package/arcade/mario-not/tests/fixtures/story5-resume-hud.txt +2 -0
  76. package/arcade/mario-not/tests/fixtures/story6-particles.txt +4 -0
  77. package/arcade/mario-not/tests/fixtures/story6-paused.txt +4 -0
  78. package/arcade/mario-not/tests/fixtures/story7-powerup.txt +4 -0
  79. package/arcade/mario-not/tests/fixtures/story8-hud-time.txt +2 -0
  80. package/arcade/mario-not/tests/fixtures/story9-hud-level.txt +2 -0
  81. package/arcade/mario-not/tiles.js +79 -0
  82. package/arcade/mario-not/tsconfig.json +14 -0
  83. package/arcade/mario-not/types.js +225 -0
  84. package/arcade/package.json +26 -0
  85. package/arcade/picman.ts +328 -0
  86. package/arcade/ping.ts +594 -0
  87. package/arcade/spice-invaders.ts +1104 -0
  88. package/arcade/tetris.ts +662 -0
  89. package/code-actions/CHANGELOG.md +4 -0
  90. package/code-actions/README.md +65 -0
  91. package/code-actions/actions.ts +107 -0
  92. package/code-actions/index.ts +148 -0
  93. package/code-actions/package.json +22 -0
  94. package/code-actions/search.ts +79 -0
  95. package/code-actions/snippets.ts +179 -0
  96. package/code-actions/ui.ts +120 -0
  97. package/files-widget/CHANGELOG.md +90 -0
  98. package/files-widget/DESIGN.md +452 -0
  99. package/files-widget/README.md +122 -0
  100. package/files-widget/TODO.md +141 -0
  101. package/files-widget/browser.ts +922 -0
  102. package/files-widget/comment.ts +5 -0
  103. package/files-widget/constants.ts +18 -0
  104. package/files-widget/demo.svg +1 -0
  105. package/files-widget/file-tree.ts +224 -0
  106. package/files-widget/file-viewer.ts +93 -0
  107. package/files-widget/git.ts +107 -0
  108. package/files-widget/index.ts +140 -0
  109. package/files-widget/input-utils.ts +3 -0
  110. package/files-widget/package.json +22 -0
  111. package/files-widget/types.ts +28 -0
  112. package/files-widget/utils.ts +26 -0
  113. package/files-widget/viewer.ts +424 -0
  114. package/import-cc-codex/research/import-chats-from-other-agents.md +135 -0
  115. package/import-cc-codex/spec.md +79 -0
  116. package/package.json +29 -0
  117. package/ralph-wiggum/CHANGELOG.md +7 -0
  118. package/ralph-wiggum/README.md +96 -0
  119. package/ralph-wiggum/SKILL.md +73 -0
  120. package/ralph-wiggum/index.ts +792 -0
  121. package/ralph-wiggum/package.json +25 -0
  122. package/raw-paste/CHANGELOG.md +7 -0
  123. package/raw-paste/README.md +52 -0
  124. package/raw-paste/index.ts +112 -0
  125. package/raw-paste/package.json +22 -0
  126. package/tab-status/CHANGELOG.md +4 -0
  127. package/tab-status/README.md +61 -0
  128. package/tab-status/assets/tab-status.png +0 -0
  129. package/tab-status/package.json +22 -0
  130. package/tab-status/tab-status.ts +179 -0
  131. package/usage-extension/CHANGELOG.md +17 -0
  132. package/usage-extension/README.md +120 -0
  133. package/usage-extension/index.ts +628 -0
  134. package/usage-extension/package.json +22 -0
  135. package/usage-extension/screenshot.png +0 -0
package/arcade/ping.ts ADDED
@@ -0,0 +1,594 @@
1
+ /**
2
+ * Ping game extension - play with /ping
3
+ */
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
6
+ import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
7
+
8
+ const GAME_WIDTH = 24;
9
+ const GAME_HEIGHT = 14;
10
+ const PLAYER_X = 0;
11
+ const AI_X = GAME_WIDTH - 1;
12
+ const PADDLE_HEIGHT = 4;
13
+ const TICK_MS = 90;
14
+ const WIN_SCORE = 5;
15
+
16
+ const BALL_DELAY_FAST = 1;
17
+ const BALL_DELAY_SLOW = 1;
18
+ const VIM_BOOST_TICKS = 12;
19
+ const POINT_PAUSE_TICKS = 10;
20
+
21
+ const CELL_WIDTH = 2;
22
+
23
+ const PLAYER_STEP = 2;
24
+ const AI_STEP = 1;
25
+ const AI_MOVE_RATE = 2 / 3;
26
+
27
+ const PING_SAVE_TYPE = "ping-save";
28
+ const LEGACY_PING_SAVE_TYPE = "paddle-ball-save";
29
+ const PING_SAVE_TYPES = new Set([PING_SAVE_TYPE, LEGACY_PING_SAVE_TYPE]);
30
+
31
+ type Direction = -1 | 1;
32
+
33
+ interface Ball {
34
+ x: number;
35
+ y: number;
36
+ vx: Direction;
37
+ vy: Direction;
38
+ }
39
+
40
+ interface GameState {
41
+ ball: Ball;
42
+ ballDelay: number;
43
+ ballCounter: number;
44
+ playerY: number;
45
+ aiY: number;
46
+ playerScore: number;
47
+ aiScore: number;
48
+ rally: number;
49
+ highRally: number;
50
+ pointPauseTicks: number;
51
+ gameOver: boolean;
52
+ }
53
+
54
+ const randomDirection = (): Direction => (Math.random() < 0.5 ? -1 : 1);
55
+
56
+ const createBall = (vx: Direction): Ball => ({
57
+ x: Math.floor(GAME_WIDTH / 2),
58
+ y: Math.floor(GAME_HEIGHT / 2),
59
+ vx,
60
+ vy: randomDirection(),
61
+ });
62
+
63
+ const createInitialState = (highRally = 0): GameState => ({
64
+ ball: createBall(randomDirection()),
65
+ ballDelay: BALL_DELAY_SLOW,
66
+ ballCounter: 0,
67
+ playerY: Math.floor((GAME_HEIGHT - PADDLE_HEIGHT) / 2),
68
+ aiY: Math.floor((GAME_HEIGHT - PADDLE_HEIGHT) / 2),
69
+ playerScore: 0,
70
+ aiScore: 0,
71
+ rally: 0,
72
+ highRally,
73
+ pointPauseTicks: 0,
74
+ gameOver: false,
75
+ });
76
+
77
+ const cloneState = (state: GameState): GameState => ({
78
+ ...state,
79
+ ball: { ...state.ball },
80
+ });
81
+
82
+ const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
83
+
84
+ class PingComponent {
85
+ private state: GameState;
86
+ private interval: ReturnType<typeof setInterval> | null = null;
87
+ private onClose: () => void;
88
+ private onSave: (state: GameState | null) => void;
89
+ private tui: { requestRender: () => void };
90
+ private cachedLines: string[] = [];
91
+ private cachedWidth = 0;
92
+ private version = 0;
93
+ private cachedVersion = -1;
94
+ private paused: boolean;
95
+ private autoPausedForWidth = false;
96
+ private resumeAfterWidth = false;
97
+ private vimBoostPending = false;
98
+ private playerBoostTicks = 0;
99
+ private aiMoveAccumulator = 0;
100
+
101
+ constructor(
102
+ tui: { requestRender: () => void },
103
+ onClose: () => void,
104
+ onSave: (state: GameState | null) => void,
105
+ savedState?: GameState,
106
+ ) {
107
+ this.tui = tui;
108
+ this.onClose = onClose;
109
+ this.onSave = onSave;
110
+
111
+ if (savedState && !savedState.gameOver) {
112
+ this.state = {
113
+ ...savedState,
114
+ ballDelay: savedState.ballDelay ?? BALL_DELAY_SLOW,
115
+ ballCounter: savedState.ballCounter ?? 0,
116
+ pointPauseTicks: savedState.pointPauseTicks ?? 0,
117
+ };
118
+ this.paused = true;
119
+ } else {
120
+ const highRally = savedState?.highRally ?? 0;
121
+ this.state = createInitialState(highRally);
122
+ this.paused = false;
123
+ this.startLoop();
124
+ }
125
+ }
126
+
127
+ private startLoop(): void {
128
+ if (this.interval) return;
129
+ this.interval = setInterval(() => {
130
+ if (this.paused || this.state.gameOver) return;
131
+ this.tick();
132
+ this.version++;
133
+ this.tui.requestRender();
134
+ }, TICK_MS);
135
+ }
136
+
137
+ private stopLoop(): void {
138
+ if (!this.interval) return;
139
+ clearInterval(this.interval);
140
+ this.interval = null;
141
+ }
142
+
143
+ private pauseForWidth(): void {
144
+ if (this.autoPausedForWidth) return;
145
+ this.autoPausedForWidth = true;
146
+ this.resumeAfterWidth = !this.paused && !this.state.gameOver;
147
+ if (this.resumeAfterWidth) {
148
+ this.paused = true;
149
+ this.stopLoop();
150
+ }
151
+ }
152
+
153
+ private resumeFromWidthPause(): void {
154
+ if (!this.autoPausedForWidth) return;
155
+ this.autoPausedForWidth = false;
156
+ if (this.resumeAfterWidth && !this.state.gameOver) {
157
+ this.paused = false;
158
+ this.startLoop();
159
+ }
160
+ this.resumeAfterWidth = false;
161
+ }
162
+
163
+ private tick(): void {
164
+ if (this.state.pointPauseTicks > 0) {
165
+ this.state.pointPauseTicks -= 1;
166
+ return;
167
+ }
168
+ this.updateAI();
169
+ if (this.state.ballCounter < this.state.ballDelay - 1) {
170
+ this.state.ballCounter += 1;
171
+ return;
172
+ }
173
+ this.state.ballCounter = 0;
174
+ this.moveBall();
175
+ if (this.state.gameOver) {
176
+ this.stopLoop();
177
+ return;
178
+ }
179
+ if (this.playerBoostTicks > 0) {
180
+ if (this.state.ball.vx > 0) {
181
+ this.playerBoostTicks -= 1;
182
+ this.moveBall();
183
+ if (this.state.gameOver) {
184
+ this.stopLoop();
185
+ return;
186
+ }
187
+ } else {
188
+ this.playerBoostTicks = 0;
189
+ }
190
+ }
191
+ }
192
+
193
+ private updateAI(): void {
194
+ this.aiMoveAccumulator += AI_MOVE_RATE;
195
+ if (this.aiMoveAccumulator < 1) {
196
+ return;
197
+ }
198
+ this.aiMoveAccumulator -= 1;
199
+
200
+ const center = Math.floor((GAME_HEIGHT - PADDLE_HEIGHT) / 2);
201
+ const target = this.state.ball.vx > 0 ? this.state.ball.y : center;
202
+ const desired = clamp(target - Math.floor(PADDLE_HEIGHT / 2), 0, GAME_HEIGHT - PADDLE_HEIGHT);
203
+
204
+ if (this.state.aiY < desired) {
205
+ this.state.aiY = Math.min(desired, this.state.aiY + AI_STEP);
206
+ } else if (this.state.aiY > desired) {
207
+ this.state.aiY = Math.max(desired, this.state.aiY - AI_STEP);
208
+ }
209
+ }
210
+
211
+ private moveBall(): void {
212
+ const ball = this.state.ball;
213
+ let nextX = ball.x + ball.vx;
214
+ let nextY = ball.y + ball.vy;
215
+
216
+ if (nextY < 0 || nextY >= GAME_HEIGHT) {
217
+ ball.vy = (ball.vy === 1 ? -1 : 1) as Direction;
218
+ nextY = ball.y + ball.vy;
219
+ }
220
+
221
+ if (ball.vx < 0 && nextX <= PLAYER_X) {
222
+ if (nextX === PLAYER_X && this.isPaddleHit(this.state.playerY, nextY)) {
223
+ ball.vx = 1;
224
+ ball.vy = this.deflect(ball.vy, this.state.playerY, nextY);
225
+ this.applyPlayerHitSpeed(this.state.playerY, nextY);
226
+ ball.x = PLAYER_X + 1;
227
+ ball.y = nextY;
228
+ this.bumpRally();
229
+ return;
230
+ }
231
+ if (nextX < PLAYER_X) {
232
+ this.scorePoint("ai");
233
+ return;
234
+ }
235
+ }
236
+
237
+ if (ball.vx > 0 && nextX >= AI_X) {
238
+ if (nextX === AI_X && this.isPaddleHit(this.state.aiY, nextY)) {
239
+ ball.vx = -1;
240
+ ball.vy = this.deflect(ball.vy, this.state.aiY, nextY);
241
+ this.applyHitSpeed(this.state.aiY, nextY);
242
+ ball.x = AI_X - 1;
243
+ ball.y = nextY;
244
+ this.bumpRally();
245
+ return;
246
+ }
247
+ if (nextX > AI_X) {
248
+ this.scorePoint("player");
249
+ return;
250
+ }
251
+ }
252
+
253
+ ball.x = nextX;
254
+ ball.y = nextY;
255
+ }
256
+
257
+ private isPaddleHit(paddleY: number, ballY: number): boolean {
258
+ return ballY >= paddleY && ballY < paddleY + PADDLE_HEIGHT;
259
+ }
260
+
261
+ private deflect(currentVy: Direction, paddleY: number, ballY: number): Direction {
262
+ const center = paddleY + (PADDLE_HEIGHT - 1) / 2;
263
+ if (ballY < center) return -1;
264
+ if (ballY > center) return 1;
265
+ return currentVy;
266
+ }
267
+
268
+ private ballDelayForHit(paddleY: number, ballY: number): number {
269
+ const offset = ballY - paddleY;
270
+ const centerLow = Math.floor((PADDLE_HEIGHT - 1) / 2);
271
+ const centerHigh = Math.ceil((PADDLE_HEIGHT - 1) / 2);
272
+ if (offset >= centerLow && offset <= centerHigh) {
273
+ return BALL_DELAY_SLOW;
274
+ }
275
+ return BALL_DELAY_FAST;
276
+ }
277
+
278
+ private applyPlayerHitSpeed(paddleY: number, ballY: number): void {
279
+ if (this.vimBoostPending) {
280
+ this.state.ballDelay = BALL_DELAY_FAST;
281
+ this.state.ballCounter = 0;
282
+ this.playerBoostTicks = VIM_BOOST_TICKS;
283
+ this.vimBoostPending = false;
284
+ return;
285
+ }
286
+ this.applyHitSpeed(paddleY, ballY);
287
+ }
288
+
289
+ private applyHitSpeed(paddleY: number, ballY: number): void {
290
+ this.state.ballDelay = this.ballDelayForHit(paddleY, ballY);
291
+ this.state.ballCounter = 0;
292
+ }
293
+
294
+ private bumpRally(): void {
295
+ this.state.rally += 1;
296
+ if (this.state.rally > this.state.highRally) {
297
+ this.state.highRally = this.state.rally;
298
+ }
299
+ }
300
+
301
+ private scorePoint(winner: "player" | "ai"): void {
302
+ if (winner === "player") {
303
+ this.state.playerScore += 1;
304
+ } else {
305
+ this.state.aiScore += 1;
306
+ }
307
+
308
+ this.state.rally = 0;
309
+
310
+ const direction = winner === "player" ? 1 : -1;
311
+ this.state.ball = createBall(direction);
312
+ this.state.ballDelay = BALL_DELAY_SLOW;
313
+ this.state.ballCounter = 0;
314
+ this.state.pointPauseTicks = POINT_PAUSE_TICKS;
315
+ this.playerBoostTicks = 0;
316
+ this.vimBoostPending = false;
317
+ this.aiMoveAccumulator = 0;
318
+
319
+ if (this.state.playerScore >= WIN_SCORE || this.state.aiScore >= WIN_SCORE) {
320
+ this.state.gameOver = true;
321
+ }
322
+ }
323
+
324
+ private togglePause(): void {
325
+ this.paused = !this.paused;
326
+ if (this.paused) {
327
+ this.stopLoop();
328
+ } else {
329
+ this.startLoop();
330
+ }
331
+ this.version++;
332
+ this.tui.requestRender();
333
+ }
334
+
335
+ private restartGame(): void {
336
+ const highRally = this.state.highRally;
337
+ this.state = createInitialState(highRally);
338
+ this.paused = false;
339
+ this.vimBoostPending = false;
340
+ this.playerBoostTicks = 0;
341
+ this.aiMoveAccumulator = 0;
342
+ this.stopLoop();
343
+ this.startLoop();
344
+ this.onSave(null);
345
+ this.version++;
346
+ this.tui.requestRender();
347
+ }
348
+
349
+ handleInput(data: string): void {
350
+ if (this.paused) {
351
+ if (this.autoPausedForWidth) {
352
+ if (matchesKey(data, "escape")) {
353
+ this.dispose();
354
+ this.onSave(cloneState(this.state));
355
+ this.onClose();
356
+ return;
357
+ }
358
+ if (data === "q" || data === "Q") {
359
+ this.dispose();
360
+ this.onSave(null);
361
+ this.onClose();
362
+ return;
363
+ }
364
+ return;
365
+ }
366
+ if (matchesKey(data, "escape") || data === "q" || data === "Q") {
367
+ this.dispose();
368
+ this.onClose();
369
+ return;
370
+ }
371
+ this.paused = false;
372
+ this.startLoop();
373
+ this.version++;
374
+ this.tui.requestRender();
375
+ return;
376
+ }
377
+
378
+ if (matchesKey(data, "escape")) {
379
+ this.dispose();
380
+ this.onSave(cloneState(this.state));
381
+ this.onClose();
382
+ return;
383
+ }
384
+
385
+ if (data === "q" || data === "Q") {
386
+ this.dispose();
387
+ this.onSave(null);
388
+ this.onClose();
389
+ return;
390
+ }
391
+
392
+ if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) {
393
+ this.restartGame();
394
+ return;
395
+ }
396
+
397
+ if (data === "p" || data === "P") {
398
+ this.togglePause();
399
+ return;
400
+ }
401
+
402
+ if (
403
+ matchesKey(data, "up") ||
404
+ data === "w" ||
405
+ data === "W" ||
406
+ data === "k" ||
407
+ data === "K"
408
+ ) {
409
+ this.state.playerY = clamp(this.state.playerY - PLAYER_STEP, 0, GAME_HEIGHT - PADDLE_HEIGHT);
410
+ this.vimBoostPending = data === "k" || data === "K";
411
+ this.version++;
412
+ this.tui.requestRender();
413
+ return;
414
+ }
415
+
416
+ if (
417
+ matchesKey(data, "down") ||
418
+ data === "s" ||
419
+ data === "S" ||
420
+ data === "j" ||
421
+ data === "J"
422
+ ) {
423
+ this.state.playerY = clamp(this.state.playerY + PLAYER_STEP, 0, GAME_HEIGHT - PADDLE_HEIGHT);
424
+ this.vimBoostPending = data === "j" || data === "J";
425
+ this.version++;
426
+ this.tui.requestRender();
427
+ return;
428
+ }
429
+ }
430
+
431
+ invalidate(): void {
432
+ this.cachedWidth = 0;
433
+ }
434
+
435
+ render(width: number): string[] {
436
+ if (width === this.cachedWidth && this.cachedVersion === this.version) {
437
+ return this.cachedLines;
438
+ }
439
+
440
+ const maxCells = Math.floor((width - 2) / CELL_WIDTH);
441
+ if (maxCells < GAME_WIDTH) {
442
+ this.pauseForWidth();
443
+ const message = "Ping needs a wider terminal. Resize to resume.";
444
+ const line = truncateToWidth(message, width);
445
+ this.cachedLines = [line];
446
+ this.cachedWidth = width;
447
+ this.cachedVersion = this.version;
448
+ return this.cachedLines;
449
+ }
450
+
451
+ this.resumeFromWidthPause();
452
+
453
+ const renderWidth = Math.min(GAME_WIDTH, maxCells);
454
+ const boxWidth = renderWidth * CELL_WIDTH;
455
+
456
+ const color = (code: string, text: string) => `\x1b[${code}m${text}\x1b[0m`;
457
+ const dim = (text: string) => color("2", text);
458
+ const accent = (text: string) => color("1;36", text);
459
+ const playerColor = (text: string) => color("1;32", text);
460
+ const aiColor = (text: string) => color("1;31", text);
461
+ const ballColor = (text: string) => color("1;33", text);
462
+ const ballGlyph = ballColor("ππ");
463
+
464
+ const lines: string[] = [];
465
+ const topBorder = dim(`+${"-".repeat(boxWidth)}+`);
466
+
467
+ lines.push(this.padLine(topBorder, width));
468
+
469
+ const titleLine = `${accent("PING")} ${dim(`First to ${WIN_SCORE}`)}`;
470
+ const scoreLine =
471
+ `Player: ${playerColor(String(this.state.playerScore))} ` +
472
+ `Pi: ${aiColor(String(this.state.aiScore))} ` +
473
+ `Rally: ${ballColor(String(this.state.rally))} ` +
474
+ `Best: ${dim(String(this.state.highRally))}`;
475
+
476
+ lines.push(this.padLine(this.boxLine(titleLine, boxWidth), width));
477
+ lines.push(this.padLine(this.boxLine(scoreLine, boxWidth), width));
478
+ lines.push(this.padLine(this.boxLine(dim("-".repeat(boxWidth)), boxWidth), width));
479
+
480
+ const showPointScore = this.state.pointPauseTicks > 0 && this.state.ball.y > 0;
481
+ const pointScoreCells = showPointScore
482
+ ? [
483
+ playerColor(String(this.state.playerScore).padEnd(CELL_WIDTH)),
484
+ dim("- "),
485
+ aiColor(String(this.state.aiScore).padEnd(CELL_WIDTH)),
486
+ ]
487
+ : [];
488
+
489
+ for (let y = 0; y < GAME_HEIGHT; y++) {
490
+ const rowCells: string[] = [];
491
+ for (let x = 0; x < renderWidth; x++) {
492
+ if (x === this.state.ball.x && y === this.state.ball.y) {
493
+ rowCells.push(ballGlyph);
494
+ continue;
495
+ }
496
+ if (x === PLAYER_X && y >= this.state.playerY && y < this.state.playerY + PADDLE_HEIGHT) {
497
+ rowCells.push(playerColor("||"));
498
+ continue;
499
+ }
500
+ if (x === AI_X && y >= this.state.aiY && y < this.state.aiY + PADDLE_HEIGHT) {
501
+ rowCells.push(aiColor("||"));
502
+ continue;
503
+ }
504
+ rowCells.push(" ");
505
+ }
506
+ if (showPointScore && y === this.state.ball.y - 1) {
507
+ const start = clamp(
508
+ this.state.ball.x - Math.floor(pointScoreCells.length / 2),
509
+ 0,
510
+ renderWidth - pointScoreCells.length,
511
+ );
512
+ for (let i = 0; i < pointScoreCells.length; i++) {
513
+ rowCells[start + i] = pointScoreCells[i];
514
+ }
515
+ }
516
+ lines.push(this.padLine(`|${rowCells.join("")}|`, width));
517
+ }
518
+
519
+ lines.push(this.padLine(this.boxLine(dim("-".repeat(boxWidth)), boxWidth), width));
520
+
521
+ let footer: string;
522
+ if (this.paused) {
523
+ footer = `${accent("PAUSED")} - Press any key to resume, ${accent("Q")} to quit`;
524
+ } else if (this.state.gameOver) {
525
+ if (this.state.playerScore >= WIN_SCORE) {
526
+ footer = `${playerColor("YOU WIN!")} - Press ${accent("R")} to restart, ${accent("Q")} to quit`;
527
+ } else {
528
+ footer = `${color("31;1", "GAME OVER")} - Press ${accent("R")} to restart, ${accent("Q")} to quit`;
529
+ }
530
+ } else {
531
+ footer = `Up/Down or W/S move, ${accent("P")} pause, ${accent("ESC")} save`;
532
+ }
533
+ lines.push(this.padLine(this.boxLine(footer, boxWidth), width));
534
+ lines.push(this.padLine(topBorder, width));
535
+
536
+ this.cachedLines = lines;
537
+ this.cachedWidth = width;
538
+ this.cachedVersion = this.version;
539
+
540
+ return lines;
541
+ }
542
+
543
+ private boxLine(content: string, width: number): string {
544
+ const truncated = truncateToWidth(content, width);
545
+ const padding = Math.max(0, width - visibleWidth(truncated));
546
+ return `|${truncated}${" ".repeat(padding)}|`;
547
+ }
548
+
549
+ private padLine(line: string, width: number): string {
550
+ const truncated = truncateToWidth(line, width);
551
+ const padding = Math.max(0, width - visibleWidth(truncated));
552
+ return truncated + " ".repeat(padding);
553
+ }
554
+
555
+ dispose(): void {
556
+ this.stopLoop();
557
+ }
558
+ }
559
+
560
+ export default function (pi: ExtensionAPI) {
561
+ const runGame = async (_args: string, ctx: ExtensionCommandContext) => {
562
+ if (!ctx.hasUI) {
563
+ ctx.ui.notify("Ping requires interactive mode", "error");
564
+ return;
565
+ }
566
+
567
+ const entries = ctx.sessionManager.getEntries();
568
+ let savedState: GameState | undefined;
569
+ for (let i = entries.length - 1; i >= 0; i--) {
570
+ const entry = entries[i];
571
+ if (entry.type === "custom" && PING_SAVE_TYPES.has(entry.customType)) {
572
+ savedState = entry.data as GameState;
573
+ break;
574
+ }
575
+ }
576
+
577
+ await ctx.ui.custom((tui, _theme, _kb, done) => {
578
+ return new PingComponent(
579
+ tui,
580
+ () => done(undefined),
581
+ (state) => {
582
+ pi.appendEntry(PING_SAVE_TYPE, state);
583
+ },
584
+ savedState,
585
+ );
586
+ });
587
+ };
588
+
589
+ pi.registerCommand("ping", {
590
+ description: "Play Ping (Pong-style)!",
591
+ handler: runGame,
592
+ });
593
+
594
+ }