@littlepartytime/sdk 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GAME_DEV_GUIDE.md +981 -0
- package/README.md +51 -0
- package/package.json +2 -2
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
# Little Party Time SDK - Game Development Guide
|
|
2
|
+
|
|
3
|
+
This guide defines the specification for developing games on the Little Party Time platform. Follow this guide precisely to create a compatible game package.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
A game consists of two parts:
|
|
8
|
+
1. **Engine** (server-side): Pure logic, no UI. Manages game state, validates actions, determines winners.
|
|
9
|
+
2. **Renderer** (client-side): React component that displays the game UI and sends player actions.
|
|
10
|
+
|
|
11
|
+
Both parts communicate through the platform via well-defined interfaces.
|
|
12
|
+
|
|
13
|
+
## Project Structure
|
|
14
|
+
|
|
15
|
+
Each game is a standalone project:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
my-game/
|
|
19
|
+
├── package.json
|
|
20
|
+
├── lpt.config.ts # Project configuration (gameId for local dev)
|
|
21
|
+
├── rules.md # Game rules (Markdown, included in upload)
|
|
22
|
+
├── vite.config.ts # Build configuration
|
|
23
|
+
├── vitest.config.ts # Test configuration
|
|
24
|
+
├── tsconfig.json
|
|
25
|
+
├── assets/ # Required game images
|
|
26
|
+
│ ├── icon.png # 1:1 (256x256+) - game list icon
|
|
27
|
+
│ ├── banner.png # 16:9 (640x360+) - lobby banner
|
|
28
|
+
│ ├── cover.png # 21:9 (840x360+) - store/featured cover
|
|
29
|
+
│ └── splash.png # 9:21 (360x840+) - loading screen
|
|
30
|
+
├── src/
|
|
31
|
+
│ ├── config.ts # GameConfig - metadata
|
|
32
|
+
│ ├── engine.ts # GameEngine - server-side logic
|
|
33
|
+
│ ├── renderer.tsx # GameRenderer - client-side React component
|
|
34
|
+
│ ├── types.ts # Game-specific types (actions, state shape)
|
|
35
|
+
│ └── index.ts # Re-exports config, engine, renderer
|
|
36
|
+
├── tests/
|
|
37
|
+
│ ├── engine.test.ts # Engine unit tests
|
|
38
|
+
│ └── e2e.e2e.test.ts # Playwright E2E tests (optional)
|
|
39
|
+
└── dist/ # Build output (generated)
|
|
40
|
+
├── bundle.js # Client-side bundle
|
|
41
|
+
├── engine.cjs # Server-side engine
|
|
42
|
+
└── <gameId>.zip # Upload package
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Create a new game project
|
|
49
|
+
npx create-littlepartytime-game my-awesome-game
|
|
50
|
+
cd my-awesome-game
|
|
51
|
+
|
|
52
|
+
# Install dependencies
|
|
53
|
+
npm install
|
|
54
|
+
|
|
55
|
+
# Run tests
|
|
56
|
+
npm test
|
|
57
|
+
|
|
58
|
+
# Build and package for upload
|
|
59
|
+
npm run pack
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Step-by-Step Implementation
|
|
63
|
+
|
|
64
|
+
### Step 1: Define Game Config
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
// src/config.ts
|
|
68
|
+
import type { GameConfig } from "@littlepartytime/sdk";
|
|
69
|
+
|
|
70
|
+
const config: GameConfig = {
|
|
71
|
+
name: "My Game", // Display name (Chinese preferred for CN users)
|
|
72
|
+
description: "...", // Brief description
|
|
73
|
+
assets: {
|
|
74
|
+
icon: "assets/icon.png", // 1:1 game list icon
|
|
75
|
+
banner: "assets/banner.png", // 16:9 lobby banner
|
|
76
|
+
cover: "assets/cover.png", // 21:9 store/featured cover
|
|
77
|
+
splash: "assets/splash.png", // 9:21 loading screen
|
|
78
|
+
},
|
|
79
|
+
minPlayers: 2,
|
|
80
|
+
maxPlayers: 6,
|
|
81
|
+
tags: ["strategy", "card"],
|
|
82
|
+
version: "1.0.0",
|
|
83
|
+
sdkVersion: "2.0.0",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export default config;
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Step 2: Define Game-Specific Types
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// src/types.ts
|
|
93
|
+
import type { GameAction, GameState } from "@littlepartytime/sdk";
|
|
94
|
+
|
|
95
|
+
// Define all possible actions your game supports
|
|
96
|
+
export type MyGameAction =
|
|
97
|
+
| { type: "PLAY_CARD"; payload: { cardId: string } }
|
|
98
|
+
| { type: "DRAW_CARD" }
|
|
99
|
+
| { type: "PASS" };
|
|
100
|
+
|
|
101
|
+
// Define the shape of your game's data field in GameState
|
|
102
|
+
export interface MyGameData {
|
|
103
|
+
deck: Card[];
|
|
104
|
+
currentPlayerIndex: number;
|
|
105
|
+
direction: 1 | -1;
|
|
106
|
+
// ... game-specific fields
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// GameState.data will be typed as Record<string, unknown> at the SDK level.
|
|
110
|
+
// Cast it in your engine: const data = state.data as MyGameData;
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Step 3: Implement Game Engine
|
|
114
|
+
|
|
115
|
+
The engine is the core of your game. It MUST be a pure, deterministic state machine.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// src/engine.ts
|
|
119
|
+
import type { GameEngine, GameState, Player, GameAction, GameResult } from "@littlepartytime/sdk";
|
|
120
|
+
|
|
121
|
+
const engine: GameEngine = {
|
|
122
|
+
/**
|
|
123
|
+
* Initialize game state for the given players.
|
|
124
|
+
* Called once when the host starts the game.
|
|
125
|
+
*
|
|
126
|
+
* @param players - Array of players in the game (from room's online members)
|
|
127
|
+
* @param options - Optional game settings (e.g., difficulty, variant rules)
|
|
128
|
+
* @returns Initial GameState
|
|
129
|
+
*/
|
|
130
|
+
init(players: Player[], options?: Record<string, unknown>): GameState {
|
|
131
|
+
return {
|
|
132
|
+
phase: "playing", // Use meaningful phase names: "playing", "voting", "scoring", etc.
|
|
133
|
+
players: players.map(p => ({
|
|
134
|
+
id: p.id,
|
|
135
|
+
// Add per-player state here (hand, score, etc.)
|
|
136
|
+
})),
|
|
137
|
+
data: {
|
|
138
|
+
// Global game state (deck, board, current turn, etc.)
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Process a player action and return the new state.
|
|
145
|
+
* This is the main game logic function.
|
|
146
|
+
*
|
|
147
|
+
* IMPORTANT:
|
|
148
|
+
* - MUST be pure: do not mutate the input state, return a new object.
|
|
149
|
+
* - MUST validate the action: check if it's the player's turn, if the action is legal, etc.
|
|
150
|
+
* - If the action is invalid, return the state unchanged (or throw an error).
|
|
151
|
+
*
|
|
152
|
+
* @param state - Current game state
|
|
153
|
+
* @param playerId - ID of the player performing the action
|
|
154
|
+
* @param action - The action being performed
|
|
155
|
+
* @returns New GameState after applying the action
|
|
156
|
+
*/
|
|
157
|
+
handleAction(state: GameState, playerId: string, action: GameAction): GameState {
|
|
158
|
+
// 1. Validate it's this player's turn
|
|
159
|
+
// 2. Validate the action is legal
|
|
160
|
+
// 3. Apply the action and return new state
|
|
161
|
+
// 4. Advance to next player or next phase as needed
|
|
162
|
+
return { ...state };
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check if the game has ended.
|
|
167
|
+
* Called after every handleAction.
|
|
168
|
+
*
|
|
169
|
+
* @param state - Current game state
|
|
170
|
+
* @returns true if the game is over
|
|
171
|
+
*/
|
|
172
|
+
isGameOver(state: GameState): boolean {
|
|
173
|
+
return state.phase === "ended";
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compute final results/rankings.
|
|
178
|
+
* Called only when isGameOver returns true.
|
|
179
|
+
*
|
|
180
|
+
* @param state - Final game state
|
|
181
|
+
* @returns GameResult with player rankings
|
|
182
|
+
*/
|
|
183
|
+
getResult(state: GameState): GameResult {
|
|
184
|
+
return {
|
|
185
|
+
rankings: state.players.map((p, i) => ({
|
|
186
|
+
playerId: p.id,
|
|
187
|
+
rank: i + 1,
|
|
188
|
+
score: 0,
|
|
189
|
+
isWinner: i === 0,
|
|
190
|
+
})),
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Filter the state to show only what a specific player should see.
|
|
196
|
+
* This is critical for games with hidden information (e.g., cards in hand).
|
|
197
|
+
*
|
|
198
|
+
* For games with no hidden info, you can return the full state.
|
|
199
|
+
* For games with hidden info:
|
|
200
|
+
* - Hide other players' hands
|
|
201
|
+
* - Hide the deck contents
|
|
202
|
+
* - Only reveal public information
|
|
203
|
+
*
|
|
204
|
+
* @param state - Full game state
|
|
205
|
+
* @param playerId - The player requesting their view
|
|
206
|
+
* @returns Filtered state visible to this player
|
|
207
|
+
*/
|
|
208
|
+
getPlayerView(state: GameState, playerId: string): Partial<GameState> {
|
|
209
|
+
return state; // Override for hidden information games
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export default engine;
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Step 4: Implement Game Renderer
|
|
217
|
+
|
|
218
|
+
The renderer is a React component that receives the platform API and the player's visible state.
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
// src/renderer.tsx
|
|
222
|
+
"use client";
|
|
223
|
+
|
|
224
|
+
import { useState, useEffect, useCallback } from "react";
|
|
225
|
+
import type { GameRendererProps, GameAction } from "@littlepartytime/sdk";
|
|
226
|
+
|
|
227
|
+
export default function GameRenderer({ platform, state }: GameRendererProps) {
|
|
228
|
+
// Access player info
|
|
229
|
+
const me = platform.getLocalPlayer();
|
|
230
|
+
const players = platform.getPlayers();
|
|
231
|
+
|
|
232
|
+
// Listen for state updates from the server
|
|
233
|
+
const [gameState, setGameState] = useState(state);
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
const handleStateUpdate = (newState: typeof state) => {
|
|
237
|
+
setGameState(newState);
|
|
238
|
+
};
|
|
239
|
+
platform.on("stateUpdate", handleStateUpdate);
|
|
240
|
+
return () => platform.off("stateUpdate", handleStateUpdate);
|
|
241
|
+
}, [platform]);
|
|
242
|
+
|
|
243
|
+
// Send actions to the server
|
|
244
|
+
const sendAction = useCallback(
|
|
245
|
+
(action: GameAction) => {
|
|
246
|
+
platform.send(action);
|
|
247
|
+
},
|
|
248
|
+
[platform]
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// Render game UI
|
|
252
|
+
return (
|
|
253
|
+
<div>
|
|
254
|
+
{/* Your game UI here */}
|
|
255
|
+
{/* Use Tailwind CSS classes - the platform provides Tailwind */}
|
|
256
|
+
{/* Use the design tokens from the platform: bg-bg-primary, text-accent, etc. */}
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Step 5: Create Index File
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
// src/index.ts
|
|
266
|
+
export { default as config } from "./config";
|
|
267
|
+
export { default as engine } from "./engine";
|
|
268
|
+
export { default as GameRenderer } from "./renderer";
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Testing Your Game
|
|
272
|
+
|
|
273
|
+
The SDK provides testing utilities to help you write tests for your game engine.
|
|
274
|
+
|
|
275
|
+
### Using createMockPlayers
|
|
276
|
+
|
|
277
|
+
Generate test players quickly:
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
import { createMockPlayers } from '@littlepartytime/sdk/testing';
|
|
281
|
+
|
|
282
|
+
// Create 3 players with default IDs (player-1, player-2, player-3)
|
|
283
|
+
const players = createMockPlayers(3);
|
|
284
|
+
|
|
285
|
+
// The first player is the host by default
|
|
286
|
+
console.log(players[0].isHost); // true
|
|
287
|
+
|
|
288
|
+
// Override specific player properties
|
|
289
|
+
const customPlayers = createMockPlayers(2, [
|
|
290
|
+
{ nickname: 'Alice' },
|
|
291
|
+
{ nickname: 'Bob', isHost: true }
|
|
292
|
+
]);
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Using GameTester for Unit Tests
|
|
296
|
+
|
|
297
|
+
`GameTester` provides a simple wrapper for testing individual engine methods:
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
import { describe, it, expect } from 'vitest';
|
|
301
|
+
import { GameTester, createMockPlayers } from '@littlepartytime/sdk/testing';
|
|
302
|
+
import engine from '../src/engine';
|
|
303
|
+
|
|
304
|
+
describe('My Game Engine', () => {
|
|
305
|
+
it('should initialize with correct phase', () => {
|
|
306
|
+
const tester = new GameTester(engine);
|
|
307
|
+
tester.init(createMockPlayers(3));
|
|
308
|
+
|
|
309
|
+
expect(tester.phase).toBe('playing');
|
|
310
|
+
expect(tester.playerStates).toHaveLength(3);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should handle valid actions', () => {
|
|
314
|
+
const tester = new GameTester(engine);
|
|
315
|
+
const players = createMockPlayers(2);
|
|
316
|
+
tester.init(players);
|
|
317
|
+
|
|
318
|
+
// Perform an action
|
|
319
|
+
tester.act(players[0].id, { type: 'PLAY_CARD', payload: { cardId: '1' } });
|
|
320
|
+
|
|
321
|
+
// Assert state changes
|
|
322
|
+
expect(tester.state.data.cardsPlayed).toContain('1');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should reject invalid actions', () => {
|
|
326
|
+
const tester = new GameTester(engine);
|
|
327
|
+
const players = createMockPlayers(2);
|
|
328
|
+
tester.init(players);
|
|
329
|
+
|
|
330
|
+
const before = tester.state;
|
|
331
|
+
// Wrong player tries to act
|
|
332
|
+
tester.act(players[1].id, { type: 'PLAY_CARD', payload: { cardId: '1' } });
|
|
333
|
+
|
|
334
|
+
// State should be unchanged
|
|
335
|
+
expect(tester.state).toBe(before);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should filter player views correctly', () => {
|
|
339
|
+
const tester = new GameTester(engine);
|
|
340
|
+
const players = createMockPlayers(2);
|
|
341
|
+
tester.init(players);
|
|
342
|
+
|
|
343
|
+
const view = tester.getPlayerView(players[0].id);
|
|
344
|
+
expect(view.data).not.toHaveProperty('secretInfo');
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Using GameSimulator for E2E Tests
|
|
350
|
+
|
|
351
|
+
`GameSimulator` helps you simulate complete game sessions:
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
import { describe, it, expect } from 'vitest';
|
|
355
|
+
import { GameSimulator } from '@littlepartytime/sdk/testing';
|
|
356
|
+
import engine from '../src/engine';
|
|
357
|
+
|
|
358
|
+
describe('My Game E2E', () => {
|
|
359
|
+
it('should play a complete game', () => {
|
|
360
|
+
const sim = new GameSimulator(engine, { playerCount: 3 });
|
|
361
|
+
sim.start();
|
|
362
|
+
|
|
363
|
+
// Simulate game actions using player indices (0, 1, 2)
|
|
364
|
+
sim.act(0, { type: 'PLAY_CARD', payload: { cardId: '1' } });
|
|
365
|
+
sim.act(1, { type: 'DRAW_CARD' });
|
|
366
|
+
sim.act(2, { type: 'PASS' });
|
|
367
|
+
|
|
368
|
+
// Continue until game ends
|
|
369
|
+
while (!sim.isGameOver()) {
|
|
370
|
+
const turn = sim.currentTurn;
|
|
371
|
+
sim.act(turn, { type: 'PASS' });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Verify results
|
|
375
|
+
expect(sim.isGameOver()).toBe(true);
|
|
376
|
+
const result = sim.getResult();
|
|
377
|
+
expect(result.rankings).toHaveLength(3);
|
|
378
|
+
|
|
379
|
+
// Access the action log for debugging
|
|
380
|
+
console.log(`Game finished in ${sim.actionLog.length} actions`);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('should provide correct player views', () => {
|
|
384
|
+
const sim = new GameSimulator(engine, { playerCount: 2 });
|
|
385
|
+
sim.start();
|
|
386
|
+
|
|
387
|
+
// Each player sees only their own view
|
|
388
|
+
const view0 = sim.getView(0);
|
|
389
|
+
const view1 = sim.getView(1);
|
|
390
|
+
|
|
391
|
+
expect(view0).not.toEqual(view1);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
## Local Development Server
|
|
397
|
+
|
|
398
|
+
The dev-kit provides a local development server for previewing and testing your game without uploading to the platform.
|
|
399
|
+
|
|
400
|
+
### Starting the Dev Server
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
# From your game project directory
|
|
404
|
+
npm run dev
|
|
405
|
+
# or directly:
|
|
406
|
+
npx lpt-dev-kit dev
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
This starts two servers and provides three pages:
|
|
410
|
+
|
|
411
|
+
```
|
|
412
|
+
Preview: http://localhost:4000/preview # Single-player preview with engine
|
|
413
|
+
Multiplayer: http://localhost:4000/play # Multi-player via Socket.IO
|
|
414
|
+
Debug Panel: http://localhost:4000/debug # Real-time state inspection
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Preview Page (Single-Player)
|
|
418
|
+
|
|
419
|
+
The Preview page runs your **engine locally in the browser** — no network needed.
|
|
420
|
+
|
|
421
|
+
Features:
|
|
422
|
+
- **Engine integration**: `platform.send()` calls `engine.handleAction()` locally, computes `getPlayerView()`, and triggers `stateUpdate` events automatically
|
|
423
|
+
- **Player switching**: Switch between players (2-32) via a dropdown to see each player's filtered view
|
|
424
|
+
- **State editor**: View and override the full game state as JSON for debugging
|
|
425
|
+
- **Action log**: See all actions sent by players in real-time
|
|
426
|
+
- **Game over detection**: Automatically detects when `isGameOver()` returns true and displays results
|
|
427
|
+
- **Reset**: Re-initialize the game at any time
|
|
428
|
+
|
|
429
|
+
This enables a rapid development cycle: **edit code -> refresh browser -> test immediately**.
|
|
430
|
+
|
|
431
|
+
### Multiplayer Page
|
|
432
|
+
|
|
433
|
+
The Play page runs your game with real Socket.IO multiplayer:
|
|
434
|
+
1. Open multiple browser tabs/windows to `http://localhost:4000/play`
|
|
435
|
+
2. Each tab enters a different nickname and joins the lobby
|
|
436
|
+
3. All players click "Ready", then the host starts the game
|
|
437
|
+
4. Actions flow through Socket.IO to the engine running on the dev server
|
|
438
|
+
|
|
439
|
+
### Debug Page
|
|
440
|
+
|
|
441
|
+
The Debug page shows the raw room state and full (unfiltered) game state in real-time. Useful for inspecting hidden information during development.
|
|
442
|
+
|
|
443
|
+
## Playwright E2E Testing
|
|
444
|
+
|
|
445
|
+
For automated UI testing, the dev-kit provides a `GamePreview` class that orchestrates the dev server and Playwright browser.
|
|
446
|
+
|
|
447
|
+
### Setup
|
|
448
|
+
|
|
449
|
+
```bash
|
|
450
|
+
# Install playwright as a dev dependency
|
|
451
|
+
npm install -D playwright
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Writing E2E Tests
|
|
455
|
+
|
|
456
|
+
Create a test file (e.g., `tests/e2e.e2e.test.ts`):
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
460
|
+
import { GamePreview } from '@littlepartytime/dev-kit/testing';
|
|
461
|
+
import path from 'path';
|
|
462
|
+
|
|
463
|
+
describe('My Game E2E', () => {
|
|
464
|
+
let preview: GamePreview;
|
|
465
|
+
|
|
466
|
+
beforeAll(async () => {
|
|
467
|
+
preview = new GamePreview({
|
|
468
|
+
projectDir: path.resolve(__dirname, '..'),
|
|
469
|
+
playerCount: 3,
|
|
470
|
+
headless: true,
|
|
471
|
+
port: 4100, // Use non-default ports to avoid conflicts
|
|
472
|
+
socketPort: 4101,
|
|
473
|
+
});
|
|
474
|
+
await preview.start();
|
|
475
|
+
}, 30000); // Allow time for server startup and browser launch
|
|
476
|
+
|
|
477
|
+
afterAll(async () => {
|
|
478
|
+
await preview.stop();
|
|
479
|
+
}, 10000);
|
|
480
|
+
|
|
481
|
+
it('should show lobby with all players', async () => {
|
|
482
|
+
const page = preview.getPlayerPage(0);
|
|
483
|
+
await expect(page.locator('text=Alice')).toBeVisible();
|
|
484
|
+
await expect(page.locator('text=Bob')).toBeVisible();
|
|
485
|
+
await expect(page.locator('text=Carol')).toBeVisible();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('should play through a complete game', async () => {
|
|
489
|
+
// All players click "Ready"
|
|
490
|
+
await preview.readyAll();
|
|
491
|
+
|
|
492
|
+
// Host starts the game
|
|
493
|
+
await preview.startGame();
|
|
494
|
+
|
|
495
|
+
// Interact with the game UI via Playwright
|
|
496
|
+
const hostPage = preview.getPlayerPage(0);
|
|
497
|
+
const player2Page = preview.getPlayerPage(1);
|
|
498
|
+
|
|
499
|
+
// Use standard Playwright assertions
|
|
500
|
+
await expect(hostPage.locator('.game-board')).toBeVisible({ timeout: 5000 });
|
|
501
|
+
|
|
502
|
+
// Send actions by interacting with the UI
|
|
503
|
+
await hostPage.click('button:has-text("Play")');
|
|
504
|
+
|
|
505
|
+
// Assert state changes on other players' screens
|
|
506
|
+
await expect(player2Page.locator('text=waiting')).toBeVisible();
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
### GamePreview API
|
|
512
|
+
|
|
513
|
+
```typescript
|
|
514
|
+
import { GamePreview } from '@littlepartytime/dev-kit/testing';
|
|
515
|
+
|
|
516
|
+
const preview = new GamePreview({
|
|
517
|
+
projectDir: string; // Absolute path to game project
|
|
518
|
+
playerCount: number; // Number of players (2-8)
|
|
519
|
+
port?: number; // Vite server port (default: 4100)
|
|
520
|
+
socketPort?: number; // Socket.IO port (default: 4101)
|
|
521
|
+
headless?: boolean; // Headless browser (default: true)
|
|
522
|
+
browserType?: 'chromium' | 'firefox' | 'webkit'; // default: 'chromium'
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
await preview.start(); // Start server + browser, join all players
|
|
526
|
+
const page = preview.getPlayerPage(0); // Get Playwright Page for player (0 = host)
|
|
527
|
+
const pages = preview.getPlayerPages(); // Get all Page objects
|
|
528
|
+
await preview.readyAll(); // Click "Ready" for all players
|
|
529
|
+
await preview.startGame(); // Host clicks "Start Game"
|
|
530
|
+
await preview.stop(); // Clean up browser + server
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### Excluding E2E Tests from Unit Test Runs
|
|
534
|
+
|
|
535
|
+
E2E tests are slower and require playwright. Exclude them from the default `vitest run`:
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
// vitest.config.ts
|
|
539
|
+
export default defineConfig({
|
|
540
|
+
test: {
|
|
541
|
+
include: ["tests/**/*.test.ts"],
|
|
542
|
+
exclude: ["tests/**/*.e2e.test.ts"],
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
Run E2E tests separately:
|
|
548
|
+
|
|
549
|
+
```bash
|
|
550
|
+
npx vitest run tests/e2e.e2e.test.ts
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
## Building and Packaging
|
|
554
|
+
|
|
555
|
+
### The pack Command
|
|
556
|
+
|
|
557
|
+
Use `lpt-dev-kit pack` (or `npm run pack`) to build and package your game:
|
|
558
|
+
|
|
559
|
+
```bash
|
|
560
|
+
# From your game project directory
|
|
561
|
+
npm run pack
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
This command:
|
|
565
|
+
1. Runs `vite build` to compile your game
|
|
566
|
+
2. Validates the build output (checks for bundle.js and engine.cjs)
|
|
567
|
+
3. Reads `GameConfig` from the built engine to extract metadata
|
|
568
|
+
4. Validates the required image assets (format, dimensions, aspect ratio)
|
|
569
|
+
5. Validates `rules.md` exists and is non-empty
|
|
570
|
+
6. Generates `manifest.json` from your config
|
|
571
|
+
7. Creates a `.zip` file in the `dist/` directory containing code, manifest, rules, and images
|
|
572
|
+
|
|
573
|
+
### Build Output
|
|
574
|
+
|
|
575
|
+
After running `pack`, your `dist/` folder will contain:
|
|
576
|
+
|
|
577
|
+
```
|
|
578
|
+
dist/
|
|
579
|
+
├── bundle.js # Client-side bundle (React component + dependencies)
|
|
580
|
+
├── engine.cjs # Server-side engine (CommonJS for Node.js)
|
|
581
|
+
└── <gameId>.zip # Upload package
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
The `.zip` upload package contains:
|
|
585
|
+
|
|
586
|
+
```
|
|
587
|
+
<gameId>.zip
|
|
588
|
+
├── manifest.json # Auto-generated metadata (from GameConfig)
|
|
589
|
+
├── rules.md # Game rules
|
|
590
|
+
├── bundle.js # Client-side bundle
|
|
591
|
+
├── engine.cjs # Server-side engine
|
|
592
|
+
├── icon.png # 1:1 game icon
|
|
593
|
+
├── banner.png # 16:9 banner
|
|
594
|
+
├── cover.png # 21:9 cover
|
|
595
|
+
└── splash.png # 9:21 splash screen
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Configuration
|
|
599
|
+
|
|
600
|
+
The `lpt.config.ts` file configures local development. The `gameId` is a local project identifier used for the zip filename and dev server — it is NOT the platform game ID (the platform assigns that upon upload):
|
|
601
|
+
|
|
602
|
+
```typescript
|
|
603
|
+
// lpt.config.ts
|
|
604
|
+
export default {
|
|
605
|
+
gameId: 'my-awesome-game', // Local project identifier (zip filename, dev server)
|
|
606
|
+
};
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
### Vite Configuration (Important)
|
|
610
|
+
|
|
611
|
+
Your `vite.config.ts` **must** use a single entry point with `fileName` mapping. Do NOT use multiple entry points, as Vite will extract shared code into separate chunk files that won't be included in the upload package.
|
|
612
|
+
|
|
613
|
+
**Recommended configuration:**
|
|
614
|
+
|
|
615
|
+
```typescript
|
|
616
|
+
// vite.config.ts
|
|
617
|
+
import { defineConfig } from "vite";
|
|
618
|
+
import react from "@vitejs/plugin-react";
|
|
619
|
+
import path from "path";
|
|
620
|
+
|
|
621
|
+
export default defineConfig({
|
|
622
|
+
plugins: [react()],
|
|
623
|
+
build: {
|
|
624
|
+
lib: {
|
|
625
|
+
entry: path.resolve(__dirname, "src/index.ts"), // Single entry point
|
|
626
|
+
formats: ["es", "cjs"],
|
|
627
|
+
fileName: (format) => {
|
|
628
|
+
if (format === "es") return "bundle.js";
|
|
629
|
+
if (format === "cjs") return "engine.cjs";
|
|
630
|
+
return `bundle.${format}.js`;
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
rollupOptions: {
|
|
634
|
+
external: ["react", "react-dom", "react/jsx-runtime", "@littlepartytime/sdk"],
|
|
635
|
+
},
|
|
636
|
+
outDir: "dist",
|
|
637
|
+
emptyOutDir: true,
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
**Why single entry?** With multiple entry points (e.g., separate `renderer.tsx` and `engine.ts`), Vite extracts shared code into chunk files like `types-D0Vb4wB4.js`. The `pack` command only includes `bundle.js` and `engine.cjs` in the upload package — extra chunks will be missing at runtime, causing 404 errors.
|
|
643
|
+
|
|
644
|
+
**The `pack` command will reject builds with extra chunk files** to prevent this issue.
|
|
645
|
+
|
|
646
|
+
### Required Game Images
|
|
647
|
+
|
|
648
|
+
Every game must include 4 images in the `assets/` directory. These are validated and packaged by the `pack` command.
|
|
649
|
+
|
|
650
|
+
| Image | Aspect Ratio | Min Size | Purpose |
|
|
651
|
+
|-------|-------------|----------|---------|
|
|
652
|
+
| `icon.png` | 1:1 | 256x256 | Game list icon |
|
|
653
|
+
| `banner.png` | 16:9 | 640x360 | Lobby banner |
|
|
654
|
+
| `cover.png` | 21:9 | 840x360 | Store/featured cover |
|
|
655
|
+
| `splash.png` | 9:21 | 360x840 | Loading/splash screen |
|
|
656
|
+
|
|
657
|
+
**Rules:**
|
|
658
|
+
- **Formats**: PNG or WebP only
|
|
659
|
+
- **Aspect ratio**: Must match exactly (1% tolerance)
|
|
660
|
+
- **Minimum dimensions**: Must meet or exceed the minimum size
|
|
661
|
+
- **File size**: Warning if any image exceeds 2MB
|
|
662
|
+
- **Paths**: Referenced in `GameConfig.assets` as relative paths from the project root
|
|
663
|
+
|
|
664
|
+
The `pack` command reads these images, validates them, and includes them in the zip with canonical names (`icon.png`, `banner.png`, etc.).
|
|
665
|
+
|
|
666
|
+
### In-Game Assets (Audio, Fonts, etc.)
|
|
667
|
+
|
|
668
|
+
For assets used inside your game UI (not the 4 required images above), use one of these approaches:
|
|
669
|
+
|
|
670
|
+
| Approach | When to Use | How |
|
|
671
|
+
|----------|-------------|-----|
|
|
672
|
+
| **Inline in bundle** | Small assets (icons, sounds < 100KB) | Vite automatically inlines assets below `assetsInlineLimit` (default 4KB) as data URLs. Increase the limit in `vite.config.ts` if needed: `build: { assetsInlineLimit: 100000 }` |
|
|
673
|
+
| **External URL** | Large assets (images, audio, video) | Host on a CDN or external server, reference by absolute URL in your code |
|
|
674
|
+
| **CSS/SVG** | UI elements, icons | Use Tailwind CSS utilities, inline SVG components, or CSS gradients/shapes |
|
|
675
|
+
| **Emoji/Unicode** | Simple visual indicators | Use Unicode characters directly in JSX |
|
|
676
|
+
|
|
677
|
+
> **Tip:** Keep your bundle under 5MB. The `pack` command warns if `bundle.js` exceeds this limit.
|
|
678
|
+
|
|
679
|
+
### Submitting Your Game
|
|
680
|
+
|
|
681
|
+
1. Run `npm run pack` to build and package
|
|
682
|
+
2. Navigate to the Little Party Time admin panel
|
|
683
|
+
3. Upload the generated `.zip` file from `dist/`
|
|
684
|
+
4. Fill in game details and submit for review
|
|
685
|
+
|
|
686
|
+
## SDK Interfaces Reference
|
|
687
|
+
|
|
688
|
+
### Platform (injected by the platform into the renderer)
|
|
689
|
+
|
|
690
|
+
| Method | Description |
|
|
691
|
+
|--------|-------------|
|
|
692
|
+
| `getPlayers()` | Returns all players in the game |
|
|
693
|
+
| `getLocalPlayer()` | Returns the current user's Player object |
|
|
694
|
+
| `send(action)` | Sends a GameAction to the server |
|
|
695
|
+
| `on(event, handler)` | Listens for events from the server |
|
|
696
|
+
| `off(event, handler)` | Removes an event listener |
|
|
697
|
+
| `reportResult(result)` | Reports game results (called by platform, not games) |
|
|
698
|
+
|
|
699
|
+
### Events the renderer should listen for
|
|
700
|
+
|
|
701
|
+
| Event | Payload | Description |
|
|
702
|
+
|-------|---------|-------------|
|
|
703
|
+
| `stateUpdate` | `Partial<GameState>` | Player-specific state update after any action |
|
|
704
|
+
|
|
705
|
+
### GameState
|
|
706
|
+
|
|
707
|
+
```typescript
|
|
708
|
+
interface GameState {
|
|
709
|
+
phase: string; // Current game phase
|
|
710
|
+
players: PlayerState[]; // Per-player state
|
|
711
|
+
data: Record<string, unknown>; // Global game data
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
interface PlayerState {
|
|
715
|
+
id: string;
|
|
716
|
+
[key: string]: unknown; // Game-specific per-player data
|
|
717
|
+
}
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
## Rules and Constraints
|
|
721
|
+
|
|
722
|
+
### Engine Rules
|
|
723
|
+
1. **Pure functions**: `handleAction` must NOT mutate the input state. Always return a new object.
|
|
724
|
+
2. **Deterministic**: Same state + same action = same result. No `Math.random()` in `handleAction`. Use random values only in `init()` to set up the initial state (e.g., shuffle deck).
|
|
725
|
+
3. **Validate everything**: Never trust the action payload. Validate it's the correct player's turn, the action is legal, etc.
|
|
726
|
+
4. **No side effects**: No network calls, no timers, no console.log in production.
|
|
727
|
+
5. **Serializable state**: `GameState` must be JSON-serializable (no functions, no class instances, no Dates - use ISO strings).
|
|
728
|
+
|
|
729
|
+
### Renderer Rules
|
|
730
|
+
1. **React functional component**: Use hooks, not class components.
|
|
731
|
+
2. **Tailwind CSS only**: Use the platform's design tokens for consistent styling.
|
|
732
|
+
3. **Mobile-first**: Design for phone screens (375px width). The platform is a PWA.
|
|
733
|
+
4. **No direct socket access**: Only use `platform.send()` and `platform.on()`.
|
|
734
|
+
5. **Chinese UI text**: The platform targets Chinese-speaking users.
|
|
735
|
+
6. **Responsive touch targets**: Buttons should be at least 44x44px for mobile.
|
|
736
|
+
|
|
737
|
+
### Design Token Reference
|
|
738
|
+
|
|
739
|
+
Use these CSS variables / Tailwind classes for consistent styling:
|
|
740
|
+
|
|
741
|
+
| Purpose | CSS Variable | Tailwind Class |
|
|
742
|
+
|---------|-------------|----------------|
|
|
743
|
+
| Page background | `--bg-primary` | `bg-bg-primary` |
|
|
744
|
+
| Card background | `--bg-secondary` | `bg-bg-secondary` |
|
|
745
|
+
| Elevated surface | `--bg-tertiary` | `bg-bg-tertiary` |
|
|
746
|
+
| Primary accent | `--accent-primary` | `text-accent` / `bg-accent` |
|
|
747
|
+
| Primary text | `--text-primary` | `text-text-primary` |
|
|
748
|
+
| Secondary text | `--text-secondary` | `text-text-secondary` |
|
|
749
|
+
| Muted text | `--text-tertiary` | `text-text-tertiary` |
|
|
750
|
+
| Border | `--border-default` | `border-border-default` |
|
|
751
|
+
| Success | `--success` | `text-success` |
|
|
752
|
+
| Error | `--error` | `text-error` |
|
|
753
|
+
| Display font | `--font-display` | `font-display` |
|
|
754
|
+
| Body font | `--font-body` | `font-body` |
|
|
755
|
+
|
|
756
|
+
## Data Flow Diagram
|
|
757
|
+
|
|
758
|
+
```
|
|
759
|
+
Player taps button
|
|
760
|
+
│
|
|
761
|
+
▼
|
|
762
|
+
Renderer calls platform.send({ type: "PLAY_CARD", payload: { cardId: "3" } })
|
|
763
|
+
│
|
|
764
|
+
▼
|
|
765
|
+
Platform sends action via Socket.IO to server
|
|
766
|
+
│
|
|
767
|
+
▼
|
|
768
|
+
Server:
|
|
769
|
+
1. Loads engine for this game
|
|
770
|
+
2. Gets current GameState
|
|
771
|
+
3. Calls engine.handleAction(state, playerId, action) → newState
|
|
772
|
+
4. Saves newState
|
|
773
|
+
5. For each player: engine.getPlayerView(newState, playerId) → playerView
|
|
774
|
+
6. Emits "stateUpdate" to each player's socket with their view
|
|
775
|
+
7. If engine.isGameOver(newState): triggers handleGameEnd
|
|
776
|
+
│
|
|
777
|
+
▼
|
|
778
|
+
Renderer receives "stateUpdate" event with filtered state
|
|
779
|
+
│
|
|
780
|
+
▼
|
|
781
|
+
React re-renders with new state
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
## Complete Example: Number Guessing Game
|
|
785
|
+
|
|
786
|
+
See the [`examples/number-guess`](../../examples/number-guess) directory for a complete working example.
|
|
787
|
+
|
|
788
|
+
### config.ts
|
|
789
|
+
```typescript
|
|
790
|
+
import type { GameConfig } from "@littlepartytime/sdk";
|
|
791
|
+
|
|
792
|
+
const config: GameConfig = {
|
|
793
|
+
name: "Guess the Number",
|
|
794
|
+
description: "Take turns guessing a secret number between 1-100. The range narrows with each guess!",
|
|
795
|
+
assets: {
|
|
796
|
+
icon: "assets/icon.png",
|
|
797
|
+
banner: "assets/banner.png",
|
|
798
|
+
cover: "assets/cover.png",
|
|
799
|
+
splash: "assets/splash.png",
|
|
800
|
+
},
|
|
801
|
+
minPlayers: 2,
|
|
802
|
+
maxPlayers: 8,
|
|
803
|
+
tags: ["casual", "party"],
|
|
804
|
+
version: "1.0.0",
|
|
805
|
+
sdkVersion: "2.0.0",
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
export default config;
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
### types.ts
|
|
812
|
+
```typescript
|
|
813
|
+
export interface GuessGameData {
|
|
814
|
+
secretNumber: number;
|
|
815
|
+
low: number;
|
|
816
|
+
high: number;
|
|
817
|
+
currentPlayerIndex: number;
|
|
818
|
+
lastGuess: { playerId: string; guess: number; hint: "high" | "low" } | null;
|
|
819
|
+
loserId: string | null;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
export type GuessAction = { type: "GUESS"; payload: { number: number } };
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
### engine.ts
|
|
826
|
+
```typescript
|
|
827
|
+
import type { GameEngine, GameState, Player, GameAction, GameResult } from "@littlepartytime/sdk";
|
|
828
|
+
import type { GuessGameData } from "./types";
|
|
829
|
+
|
|
830
|
+
const engine: GameEngine = {
|
|
831
|
+
init(players: Player[]): GameState {
|
|
832
|
+
const secretNumber = Math.floor(Math.random() * 100) + 1;
|
|
833
|
+
return {
|
|
834
|
+
phase: "playing",
|
|
835
|
+
players: players.map(p => ({ id: p.id, nickname: p.nickname })),
|
|
836
|
+
data: {
|
|
837
|
+
secretNumber,
|
|
838
|
+
low: 1,
|
|
839
|
+
high: 100,
|
|
840
|
+
currentPlayerIndex: 0,
|
|
841
|
+
lastGuess: null,
|
|
842
|
+
loserId: null,
|
|
843
|
+
} satisfies GuessGameData as unknown as Record<string, unknown>,
|
|
844
|
+
};
|
|
845
|
+
},
|
|
846
|
+
|
|
847
|
+
handleAction(state: GameState, playerId: string, action: GameAction): GameState {
|
|
848
|
+
if (action.type !== "GUESS") return state;
|
|
849
|
+
const data = state.data as unknown as GuessGameData;
|
|
850
|
+
|
|
851
|
+
const currentPlayer = state.players[data.currentPlayerIndex];
|
|
852
|
+
if (currentPlayer.id !== playerId) return state; // Not your turn
|
|
853
|
+
|
|
854
|
+
const guess = (action.payload as { number: number }).number;
|
|
855
|
+
if (guess < data.low || guess > data.high) return state; // Out of range
|
|
856
|
+
|
|
857
|
+
if (guess === data.secretNumber) {
|
|
858
|
+
return {
|
|
859
|
+
...state,
|
|
860
|
+
phase: "ended",
|
|
861
|
+
data: {
|
|
862
|
+
...data,
|
|
863
|
+
loserId: playerId,
|
|
864
|
+
lastGuess: { playerId, guess, hint: "low" },
|
|
865
|
+
} as unknown as Record<string, unknown>,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const hint = guess > data.secretNumber ? "high" : "low";
|
|
870
|
+
const newLow = hint === "low" ? Math.max(data.low, guess + 1) : data.low;
|
|
871
|
+
const newHigh = hint === "high" ? Math.min(data.high, guess - 1) : data.high;
|
|
872
|
+
|
|
873
|
+
return {
|
|
874
|
+
...state,
|
|
875
|
+
data: {
|
|
876
|
+
...data,
|
|
877
|
+
low: newLow,
|
|
878
|
+
high: newHigh,
|
|
879
|
+
currentPlayerIndex: (data.currentPlayerIndex + 1) % state.players.length,
|
|
880
|
+
lastGuess: { playerId, guess, hint },
|
|
881
|
+
} as unknown as Record<string, unknown>,
|
|
882
|
+
};
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
isGameOver(state: GameState): boolean {
|
|
886
|
+
return state.phase === "ended";
|
|
887
|
+
},
|
|
888
|
+
|
|
889
|
+
getResult(state: GameState): GameResult {
|
|
890
|
+
const data = state.data as unknown as GuessGameData;
|
|
891
|
+
return {
|
|
892
|
+
rankings: state.players.map(p => ({
|
|
893
|
+
playerId: p.id,
|
|
894
|
+
rank: p.id === data.loserId ? state.players.length : 1,
|
|
895
|
+
score: p.id === data.loserId ? 0 : 1,
|
|
896
|
+
isWinner: p.id !== data.loserId,
|
|
897
|
+
})),
|
|
898
|
+
};
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
getPlayerView(state: GameState, playerId: string): Partial<GameState> {
|
|
902
|
+
const data = state.data as unknown as GuessGameData;
|
|
903
|
+
// Hide the secret number while game is in progress
|
|
904
|
+
if (state.phase !== "ended") {
|
|
905
|
+
return {
|
|
906
|
+
...state,
|
|
907
|
+
data: {
|
|
908
|
+
low: data.low,
|
|
909
|
+
high: data.high,
|
|
910
|
+
currentPlayerIndex: data.currentPlayerIndex,
|
|
911
|
+
lastGuess: data.lastGuess,
|
|
912
|
+
loserId: data.loserId,
|
|
913
|
+
// secretNumber is NOT included
|
|
914
|
+
} as unknown as Record<string, unknown>,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
// Reveal everything after game ends
|
|
918
|
+
return state;
|
|
919
|
+
},
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
export default engine;
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
### Test file (tests/engine.test.ts)
|
|
926
|
+
```typescript
|
|
927
|
+
import { describe, it, expect } from 'vitest';
|
|
928
|
+
import { GameTester, GameSimulator, createMockPlayers } from '@littlepartytime/sdk/testing';
|
|
929
|
+
import engine from '../src/engine';
|
|
930
|
+
|
|
931
|
+
describe('Number Guess Engine', () => {
|
|
932
|
+
describe('GameTester - unit tests', () => {
|
|
933
|
+
it('should initialize with playing phase', () => {
|
|
934
|
+
const tester = new GameTester(engine);
|
|
935
|
+
tester.init(createMockPlayers(3));
|
|
936
|
+
expect(tester.phase).toBe('playing');
|
|
937
|
+
expect(tester.playerStates).toHaveLength(3);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it('should hide secretNumber in player view', () => {
|
|
941
|
+
const tester = new GameTester(engine);
|
|
942
|
+
const players = createMockPlayers(2);
|
|
943
|
+
tester.init(players);
|
|
944
|
+
const view = tester.getPlayerView(players[0].id);
|
|
945
|
+
expect(view.data).not.toHaveProperty('secretNumber');
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
it('should reject out-of-turn actions', () => {
|
|
949
|
+
const tester = new GameTester(engine);
|
|
950
|
+
const players = createMockPlayers(2);
|
|
951
|
+
tester.init(players);
|
|
952
|
+
const before = tester.state;
|
|
953
|
+
tester.act(players[1].id, { type: 'GUESS', payload: { number: 50 } });
|
|
954
|
+
expect(tester.state).toBe(before);
|
|
955
|
+
});
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
describe('GameSimulator - E2E', () => {
|
|
959
|
+
it('should play a complete game', () => {
|
|
960
|
+
const sim = new GameSimulator(engine, { playerCount: 3 });
|
|
961
|
+
sim.start();
|
|
962
|
+
|
|
963
|
+
// Binary search to end the game quickly
|
|
964
|
+
let lo = 1, hi = 100;
|
|
965
|
+
while (!sim.isGameOver()) {
|
|
966
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
967
|
+
sim.act(sim.currentTurn, { type: 'GUESS', payload: { number: mid } });
|
|
968
|
+
if (!sim.isGameOver()) {
|
|
969
|
+
const data = sim.state.data as { low: number; high: number };
|
|
970
|
+
lo = data.low;
|
|
971
|
+
hi = data.high;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
expect(sim.isGameOver()).toBe(true);
|
|
976
|
+
const result = sim.getResult();
|
|
977
|
+
expect(result.rankings).toHaveLength(3);
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @littlepartytime/sdk
|
|
2
|
+
|
|
3
|
+
Game SDK for the [Little Party Time](https://github.com/chesterli710/littlepartytime-sdk) platform — type definitions and testing utilities for game developers.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @littlepartytime/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What's Included
|
|
12
|
+
|
|
13
|
+
- **Type definitions** — `GameConfig`, `GameEngine`, `GameState`, `GameAction`, `GameResult`, `GameRendererProps`, etc.
|
|
14
|
+
- **Testing utilities** — `GameTester`, `GameSimulator`, `createMockPlayers` (import from `@littlepartytime/sdk/testing`)
|
|
15
|
+
|
|
16
|
+
## Quick Example
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import type { GameEngine, GameState, Player } from "@littlepartytime/sdk";
|
|
20
|
+
|
|
21
|
+
const engine: GameEngine = {
|
|
22
|
+
init(players: Player[]): GameState { /* ... */ },
|
|
23
|
+
handleAction(state, playerId, action) { /* ... */ },
|
|
24
|
+
isGameOver(state) { /* ... */ },
|
|
25
|
+
getResult(state) { /* ... */ },
|
|
26
|
+
getPlayerView(state, playerId) { /* ... */ },
|
|
27
|
+
};
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Documentation
|
|
31
|
+
|
|
32
|
+
Full development guide with step-by-step instructions, testing patterns, build configuration, and asset requirements:
|
|
33
|
+
|
|
34
|
+
**[GAME_DEV_GUIDE.md](https://github.com/chesterli710/littlepartytime-sdk/blob/main/packages/sdk/GAME_DEV_GUIDE.md)**
|
|
35
|
+
|
|
36
|
+
After installing the SDK, the guide is also available locally:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cat node_modules/@littlepartytime/sdk/GAME_DEV_GUIDE.md
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Related Packages
|
|
43
|
+
|
|
44
|
+
| Package | Description |
|
|
45
|
+
|---------|-------------|
|
|
46
|
+
| [`@littlepartytime/dev-kit`](https://www.npmjs.com/package/@littlepartytime/dev-kit) | CLI dev server, build & pack toolchain |
|
|
47
|
+
| [`create-littlepartytime-game`](https://www.npmjs.com/package/create-littlepartytime-game) | Project scaffolding (`npx create-littlepartytime-game`) |
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@littlepartytime/sdk",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Game SDK for Little Party Time platform - type definitions and testing utilities",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"test": "vitest run",
|
|
23
23
|
"test:watch": "vitest"
|
|
24
24
|
},
|
|
25
|
-
"files": ["dist"],
|
|
25
|
+
"files": ["dist", "GAME_DEV_GUIDE.md"],
|
|
26
26
|
"keywords": ["boardgame", "sdk", "game-engine", "littlepartytime"],
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"repository": {
|