csterm-server 1.0.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.
@@ -0,0 +1,196 @@
1
+ // Headless WebSocket client for testing multiplayer server
2
+ // Can simulate multiple clients connecting, joining rooms, and performing actions
3
+ import WebSocket from 'ws';
4
+ export class HeadlessClient {
5
+ socket = null;
6
+ callbacks = {};
7
+ playerId = null;
8
+ playerName;
9
+ roomId = null;
10
+ team = null;
11
+ messageLog = [];
12
+ constructor(playerName = 'TestPlayer') {
13
+ this.playerName = playerName;
14
+ }
15
+ async connect(serverUrl = 'ws://localhost:8080') {
16
+ return new Promise((resolve, reject) => {
17
+ this.socket = new WebSocket(serverUrl);
18
+ this.socket.onopen = () => {
19
+ console.log(`[${this.playerName}] Connected to server`);
20
+ this.callbacks.onConnect?.();
21
+ resolve();
22
+ };
23
+ this.socket.onclose = (event) => {
24
+ const reason = event.reason || 'Connection closed';
25
+ console.log(`[${this.playerName}] Disconnected: ${reason}`);
26
+ this.callbacks.onDisconnect?.(reason);
27
+ };
28
+ this.socket.onerror = (error) => {
29
+ console.error(`[${this.playerName}] WebSocket error`);
30
+ reject(new Error('WebSocket error'));
31
+ };
32
+ this.socket.onmessage = (event) => {
33
+ this.handleMessage(event.data.toString());
34
+ };
35
+ });
36
+ }
37
+ disconnect() {
38
+ if (this.socket) {
39
+ this.socket.close();
40
+ this.socket = null;
41
+ }
42
+ }
43
+ setCallbacks(callbacks) {
44
+ this.callbacks = { ...this.callbacks, ...callbacks };
45
+ }
46
+ // Lobby operations
47
+ listRooms() {
48
+ this.send({ type: 'list_rooms' });
49
+ }
50
+ createRoom(config) {
51
+ const fullConfig = {
52
+ name: config.name || `${this.playerName}'s Room`,
53
+ map: config.map || 'dm_arena',
54
+ mode: config.mode || 'deathmatch',
55
+ maxPlayers: config.maxPlayers || 10,
56
+ botCount: config.botCount || 0,
57
+ botDifficulty: config.botDifficulty || 'medium',
58
+ isPrivate: config.isPrivate || false,
59
+ password: config.password,
60
+ };
61
+ this.send({ type: 'create_room', config: fullConfig });
62
+ }
63
+ joinRoom(roomId, password) {
64
+ this.send({
65
+ type: 'join_room',
66
+ roomId,
67
+ playerName: this.playerName,
68
+ password,
69
+ });
70
+ }
71
+ leaveRoom() {
72
+ this.send({ type: 'leave_room' });
73
+ this.roomId = null;
74
+ }
75
+ setReady() {
76
+ this.send({ type: 'ready' });
77
+ }
78
+ startGame() {
79
+ this.send({ type: 'start_game' });
80
+ }
81
+ changeTeam(team) {
82
+ this.send({ type: 'change_team', team });
83
+ }
84
+ // Game operations
85
+ sendInput(forward, strafe, yaw, pitch, jump = false) {
86
+ this.send({
87
+ type: 'input',
88
+ input: { forward, strafe, yaw, pitch, jump, crouch: false },
89
+ sequence: Date.now(),
90
+ });
91
+ }
92
+ sendFire() {
93
+ this.send({ type: 'fire' });
94
+ }
95
+ // Getters
96
+ getPlayerId() {
97
+ return this.playerId;
98
+ }
99
+ getRoomId() {
100
+ return this.roomId;
101
+ }
102
+ getTeam() {
103
+ return this.team;
104
+ }
105
+ getMessageLog() {
106
+ return this.messageLog;
107
+ }
108
+ clearMessageLog() {
109
+ this.messageLog = [];
110
+ }
111
+ // Wait for a specific message type
112
+ async waitForMessage(type, timeout = 5000) {
113
+ return new Promise((resolve, reject) => {
114
+ const startTime = Date.now();
115
+ const checkInterval = setInterval(() => {
116
+ const message = this.messageLog.find(m => m.type === type);
117
+ if (message) {
118
+ clearInterval(checkInterval);
119
+ resolve(message);
120
+ }
121
+ else if (Date.now() - startTime > timeout) {
122
+ clearInterval(checkInterval);
123
+ reject(new Error(`Timeout waiting for message type: ${type}`));
124
+ }
125
+ }, 50);
126
+ });
127
+ }
128
+ send(message) {
129
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
130
+ this.socket.send(JSON.stringify(message));
131
+ }
132
+ }
133
+ handleMessage(data) {
134
+ try {
135
+ const message = JSON.parse(data);
136
+ this.messageLog.push(message);
137
+ this.callbacks.onMessage?.(message);
138
+ this.dispatchMessage(message);
139
+ }
140
+ catch (error) {
141
+ console.error(`[${this.playerName}] Failed to parse message:`, error);
142
+ }
143
+ }
144
+ dispatchMessage(message) {
145
+ switch (message.type) {
146
+ case 'room_list':
147
+ console.log(`[${this.playerName}] Room list: ${message.rooms.length} rooms`);
148
+ this.callbacks.onRoomList?.(message.rooms);
149
+ break;
150
+ case 'room_joined':
151
+ this.playerId = message.playerId;
152
+ this.roomId = message.roomId;
153
+ console.log(`[${this.playerName}] Joined room ${message.roomId} as ${message.playerId}`);
154
+ this.callbacks.onRoomJoined?.(message.roomId, message.playerId, message.room);
155
+ break;
156
+ case 'room_error':
157
+ console.error(`[${this.playerName}] Room error: ${message.error}`);
158
+ break;
159
+ case 'player_joined':
160
+ console.log(`[${this.playerName}] Player joined: ${message.playerName} (${message.playerId})`);
161
+ this.callbacks.onPlayerJoined?.(message.playerId, message.playerName);
162
+ break;
163
+ case 'player_team_changed':
164
+ console.log(`[${this.playerName}] Player ${message.playerId} changed to team ${message.team}`);
165
+ this.callbacks.onPlayerTeamChanged?.(message.playerId, message.team);
166
+ break;
167
+ case 'assigned_team':
168
+ this.team = message.team;
169
+ console.log(`[${this.playerName}] Assigned to team: ${message.team}`);
170
+ break;
171
+ case 'player_ready':
172
+ console.log(`[${this.playerName}] Player ${message.playerId} ready: ${message.ready}`);
173
+ break;
174
+ case 'game_starting':
175
+ console.log(`[${this.playerName}] Game starting in ${message.countdown}...`);
176
+ break;
177
+ case 'phase_change':
178
+ console.log(`[${this.playerName}] Phase: ${message.phase}, Round ${message.roundNumber}`);
179
+ this.callbacks.onPhaseChange?.(message.phase, message.roundNumber, message.tScore, message.ctScore);
180
+ break;
181
+ case 'game_state':
182
+ // Frequent message, don't log
183
+ break;
184
+ case 'kill_event':
185
+ console.log(`[${this.playerName}] Kill: ${message.event.killerName} killed ${message.event.victimName}`);
186
+ break;
187
+ default:
188
+ // Ignore other messages
189
+ break;
190
+ }
191
+ }
192
+ }
193
+ // Helper function to run tests
194
+ export async function delay(ms) {
195
+ return new Promise(resolve => setTimeout(resolve, ms));
196
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env npx ts-node
2
+ export {};
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env npx ts-node
2
+ // Test script to verify team synchronization between players
3
+ // Run with: npx ts-node server/src/test/testTeamSync.ts
4
+ import { HeadlessClient, delay } from './HeadlessClient.js';
5
+ async function testTeamSync() {
6
+ console.log('=== Team Sync Test ===\n');
7
+ // Create two clients
8
+ const host = new HeadlessClient('Host');
9
+ const joiner = new HeadlessClient('Joiner');
10
+ try {
11
+ // Connect host
12
+ console.log('1. Connecting host...');
13
+ await host.connect();
14
+ await delay(100);
15
+ // Host creates room
16
+ console.log('2. Host creating room...');
17
+ host.createRoom({ name: 'Test Room', botCount: 0 });
18
+ await delay(500);
19
+ // Get room ID
20
+ const roomId = host.getRoomId();
21
+ if (!roomId) {
22
+ throw new Error('Host failed to create room');
23
+ }
24
+ console.log(` Room created: ${roomId}`);
25
+ console.log(` Host team: ${host.getTeam()}`);
26
+ // Host changes team to T
27
+ console.log('3. Host changing team to T...');
28
+ host.changeTeam('T');
29
+ await delay(200);
30
+ console.log(` Host team after change: ${host.getTeam()}`);
31
+ // Connect joiner
32
+ console.log('4. Connecting joiner...');
33
+ await joiner.connect();
34
+ await delay(100);
35
+ // Track team changes received by joiner
36
+ let receivedTeamChanges = [];
37
+ joiner.setCallbacks({
38
+ onPlayerTeamChanged: (playerId, team) => {
39
+ receivedTeamChanges.push({ playerId, team });
40
+ },
41
+ });
42
+ // Joiner joins the room
43
+ console.log('5. Joiner joining room...');
44
+ joiner.joinRoom(roomId);
45
+ await delay(500);
46
+ // Check what joiner received
47
+ console.log('\n=== Results ===');
48
+ console.log(`Joiner received ${receivedTeamChanges.length} team change messages:`);
49
+ for (const change of receivedTeamChanges) {
50
+ console.log(` - Player ${change.playerId} -> ${change.team}`);
51
+ }
52
+ // Check if host's team was received
53
+ const hostId = host.getPlayerId();
54
+ const hostTeamReceived = receivedTeamChanges.find(c => c.playerId === hostId);
55
+ if (hostTeamReceived) {
56
+ console.log(`\n✓ SUCCESS: Joiner received host's team (${hostTeamReceived.team})`);
57
+ }
58
+ else {
59
+ console.log(`\n✗ FAILURE: Joiner did NOT receive host's team`);
60
+ console.log(` Host ID: ${hostId}`);
61
+ console.log(` Messages in joiner log: ${joiner.getMessageLog().map(m => m.type).join(', ')}`);
62
+ }
63
+ // Print full message log for debugging
64
+ console.log('\n=== Joiner Message Log ===');
65
+ for (const msg of joiner.getMessageLog()) {
66
+ if (msg.type !== 'game_state') {
67
+ console.log(` ${msg.type}: ${JSON.stringify(msg).substring(0, 100)}`);
68
+ }
69
+ }
70
+ }
71
+ catch (error) {
72
+ console.error('Test failed:', error);
73
+ }
74
+ finally {
75
+ // Cleanup
76
+ host.disconnect();
77
+ joiner.disconnect();
78
+ console.log('\n=== Test Complete ===');
79
+ }
80
+ }
81
+ // Run the test
82
+ testTeamSync().catch(console.error);
@@ -0,0 +1,164 @@
1
+ import { WebSocket } from 'ws';
2
+ import { RoomConfig, TeamId, Vec3, PlayerInput, GamePhase, WeaponType, BotDifficulty } from './protocol.js';
3
+ export interface ServerConfig {
4
+ port: number;
5
+ tickRate: number;
6
+ broadcastRate: number;
7
+ maxRooms: number;
8
+ maxPlayersPerRoom: number;
9
+ roomIdleTimeout: number;
10
+ }
11
+ export declare const DEFAULT_SERVER_CONFIG: ServerConfig;
12
+ export interface ConnectedClient {
13
+ id: string;
14
+ socket: WebSocket;
15
+ name: string | null;
16
+ roomId: string | null;
17
+ isReady: boolean;
18
+ lastActivity: number;
19
+ pendingInputs: Array<{
20
+ sequence: number;
21
+ input: PlayerInput;
22
+ }>;
23
+ }
24
+ export interface ServerPlayerState {
25
+ id: string;
26
+ name: string;
27
+ team: TeamId;
28
+ position: Vec3;
29
+ velocity: Vec3;
30
+ yaw: number;
31
+ pitch: number;
32
+ health: number;
33
+ armor: number;
34
+ isAlive: boolean;
35
+ currentWeapon: WeaponType;
36
+ weapons: Map<number, ServerWeaponState>;
37
+ money: number;
38
+ kills: number;
39
+ deaths: number;
40
+ lastInputSequence: number;
41
+ }
42
+ export interface ServerWeaponState {
43
+ type: WeaponType;
44
+ currentAmmo: number;
45
+ reserveAmmo: number;
46
+ isReloading: boolean;
47
+ reloadStartTime: number;
48
+ lastFireTime: number;
49
+ }
50
+ export interface ServerBotState {
51
+ id: string;
52
+ name: string;
53
+ team: TeamId;
54
+ difficulty: BotDifficulty;
55
+ position: Vec3;
56
+ velocity: Vec3;
57
+ yaw: number;
58
+ pitch: number;
59
+ health: number;
60
+ armor: number;
61
+ isAlive: boolean;
62
+ currentWeapon: WeaponType;
63
+ kills: number;
64
+ deaths: number;
65
+ targetId: string | null;
66
+ lastTargetSeen: number;
67
+ wanderAngle: number;
68
+ nextFireTime: number;
69
+ }
70
+ export interface ServerDroppedWeapon {
71
+ id: string;
72
+ weaponType: WeaponType;
73
+ position: Vec3;
74
+ ammo: number;
75
+ reserveAmmo: number;
76
+ dropTime: number;
77
+ }
78
+ export interface SpawnPoint {
79
+ position: Vec3;
80
+ angle: number;
81
+ team: TeamId | 'DM';
82
+ }
83
+ export interface MapCollider {
84
+ min: Vec3;
85
+ max: Vec3;
86
+ }
87
+ export interface MapData {
88
+ id: string;
89
+ name: string;
90
+ spawnPoints: SpawnPoint[];
91
+ colliders: MapCollider[];
92
+ bounds: {
93
+ min: Vec3;
94
+ max: Vec3;
95
+ };
96
+ }
97
+ export interface ServerGameState {
98
+ phase: GamePhase;
99
+ phaseStartTime: number;
100
+ roundNumber: number;
101
+ tScore: number;
102
+ ctScore: number;
103
+ roundWinner: TeamId | null;
104
+ players: Map<string, ServerPlayerState>;
105
+ bots: Map<string, ServerBotState>;
106
+ droppedWeapons: Map<string, ServerDroppedWeapon>;
107
+ tick: number;
108
+ lastBroadcastTick: number;
109
+ }
110
+ export interface RoomState {
111
+ id: string;
112
+ config: RoomConfig;
113
+ hostId: string;
114
+ createdAt: number;
115
+ lastActivity: number;
116
+ clients: Map<string, ConnectedClient>;
117
+ gameState: ServerGameState | null;
118
+ gameLoopInterval: ReturnType<typeof setInterval> | null;
119
+ broadcastInterval: ReturnType<typeof setInterval> | null;
120
+ mapData: MapData;
121
+ }
122
+ export interface RaycastHit {
123
+ entityId: string;
124
+ entityType: 'player' | 'bot';
125
+ distance: number;
126
+ hitPoint: Vec3;
127
+ isHeadshot: boolean;
128
+ }
129
+ export interface ServerEconomyConfig {
130
+ startMoney: number;
131
+ maxMoney: number;
132
+ roundWinBonus: number;
133
+ roundLoseBonus: number;
134
+ roundLoseStreakBonus: number;
135
+ maxLoseStreak: number;
136
+ killReward: Record<WeaponType, number>;
137
+ }
138
+ export declare const DEFAULT_ECONOMY_CONFIG: ServerEconomyConfig;
139
+ export interface ServerWeaponDef {
140
+ type: WeaponType;
141
+ name: string;
142
+ slot: number;
143
+ damage: number;
144
+ fireRate: number;
145
+ reloadTime: number;
146
+ magazineSize: number;
147
+ reserveAmmo: number;
148
+ spread: number;
149
+ range: number;
150
+ moveSpeed: number;
151
+ pellets: number;
152
+ isAutomatic: boolean;
153
+ headshotMultiplier: number;
154
+ cost: number;
155
+ }
156
+ export declare const WEAPON_DEFS: Record<WeaponType, ServerWeaponDef>;
157
+ export declare function createVec3(x?: number, y?: number, z?: number): Vec3;
158
+ export declare function vec3Add(a: Vec3, b: Vec3): Vec3;
159
+ export declare function vec3Sub(a: Vec3, b: Vec3): Vec3;
160
+ export declare function vec3Scale(v: Vec3, s: number): Vec3;
161
+ export declare function vec3Length(v: Vec3): number;
162
+ export declare function vec3Normalize(v: Vec3): Vec3;
163
+ export declare function vec3Distance(a: Vec3, b: Vec3): number;
164
+ export declare function vec3Dot(a: Vec3, b: Vec3): number;
package/dist/types.js ADDED
@@ -0,0 +1,139 @@
1
+ // Server-specific type definitions for CS-CLI multiplayer
2
+ export const DEFAULT_SERVER_CONFIG = {
3
+ port: 8080,
4
+ tickRate: 60,
5
+ broadcastRate: 20,
6
+ maxRooms: 100,
7
+ maxPlayersPerRoom: 10,
8
+ roomIdleTimeout: 300000, // 5 minutes
9
+ };
10
+ export const DEFAULT_ECONOMY_CONFIG = {
11
+ startMoney: 800,
12
+ maxMoney: 16000,
13
+ roundWinBonus: 3250,
14
+ roundLoseBonus: 1400,
15
+ roundLoseStreakBonus: 500,
16
+ maxLoseStreak: 4,
17
+ killReward: {
18
+ knife: 1500,
19
+ pistol: 300,
20
+ rifle: 300,
21
+ shotgun: 900,
22
+ sniper: 100,
23
+ },
24
+ };
25
+ export const WEAPON_DEFS = {
26
+ knife: {
27
+ type: 'knife',
28
+ name: 'Knife',
29
+ slot: 3,
30
+ damage: 40,
31
+ fireRate: 60,
32
+ reloadTime: 0,
33
+ magazineSize: Infinity,
34
+ reserveAmmo: Infinity,
35
+ spread: 0,
36
+ range: 2,
37
+ moveSpeed: 1.0,
38
+ pellets: 1,
39
+ isAutomatic: false,
40
+ headshotMultiplier: 1.0,
41
+ cost: 0,
42
+ },
43
+ pistol: {
44
+ type: 'pistol',
45
+ name: 'Pistol',
46
+ slot: 2,
47
+ damage: 25,
48
+ fireRate: 400,
49
+ reloadTime: 2.2,
50
+ magazineSize: 12,
51
+ reserveAmmo: 36,
52
+ spread: 2,
53
+ range: 50,
54
+ moveSpeed: 1.0,
55
+ pellets: 1,
56
+ isAutomatic: false,
57
+ headshotMultiplier: 2.0,
58
+ cost: 200,
59
+ },
60
+ rifle: {
61
+ type: 'rifle',
62
+ name: 'Rifle',
63
+ slot: 1,
64
+ damage: 30,
65
+ fireRate: 600,
66
+ reloadTime: 2.5,
67
+ magazineSize: 30,
68
+ reserveAmmo: 90,
69
+ spread: 3,
70
+ range: 80,
71
+ moveSpeed: 0.9,
72
+ pellets: 1,
73
+ isAutomatic: true,
74
+ headshotMultiplier: 2.5,
75
+ cost: 2700,
76
+ },
77
+ shotgun: {
78
+ type: 'shotgun',
79
+ name: 'Shotgun',
80
+ slot: 1,
81
+ damage: 20,
82
+ fireRate: 70,
83
+ reloadTime: 0.5,
84
+ magazineSize: 8,
85
+ reserveAmmo: 32,
86
+ spread: 8,
87
+ range: 20,
88
+ moveSpeed: 0.9,
89
+ pellets: 8,
90
+ isAutomatic: false,
91
+ headshotMultiplier: 1.5,
92
+ cost: 1200,
93
+ },
94
+ sniper: {
95
+ type: 'sniper',
96
+ name: 'Sniper',
97
+ slot: 1,
98
+ damage: 100,
99
+ fireRate: 40,
100
+ reloadTime: 3.5,
101
+ magazineSize: 5,
102
+ reserveAmmo: 20,
103
+ spread: 0.5,
104
+ range: 150,
105
+ moveSpeed: 0.8,
106
+ pellets: 1,
107
+ isAutomatic: false,
108
+ headshotMultiplier: 4.0,
109
+ cost: 4750,
110
+ },
111
+ };
112
+ // ============ Utility Functions ============
113
+ export function createVec3(x = 0, y = 0, z = 0) {
114
+ return { x, y, z };
115
+ }
116
+ export function vec3Add(a, b) {
117
+ return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z };
118
+ }
119
+ export function vec3Sub(a, b) {
120
+ return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z };
121
+ }
122
+ export function vec3Scale(v, s) {
123
+ return { x: v.x * s, y: v.y * s, z: v.z * s };
124
+ }
125
+ export function vec3Length(v) {
126
+ return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
127
+ }
128
+ export function vec3Normalize(v) {
129
+ const len = vec3Length(v);
130
+ if (len === 0)
131
+ return { x: 0, y: 0, z: 0 };
132
+ return { x: v.x / len, y: v.y / len, z: v.z / len };
133
+ }
134
+ export function vec3Distance(a, b) {
135
+ return vec3Length(vec3Sub(a, b));
136
+ }
137
+ export function vec3Dot(a, b) {
138
+ return a.x * b.x + a.y * b.y + a.z * b.z;
139
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "csterm-server",
3
+ "version": "1.0.0",
4
+ "description": "Multiplayer game server for CSterm - Counter-Strike in your terminal",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "csterm-server": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "start": "node dist/index.js",
16
+ "start:hub": "node dist/index.js --hub-only",
17
+ "start:pool": "node dist/index.js --pool",
18
+ "dev": "tsc --watch & node --watch dist/index.js",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "keywords": [
22
+ "game",
23
+ "server",
24
+ "multiplayer",
25
+ "websocket",
26
+ "fps",
27
+ "counter-strike",
28
+ "terminal",
29
+ "csterm"
30
+ ],
31
+ "author": "idanbeck",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": ""
36
+ },
37
+ "engines": {
38
+ "node": ">=18.0.0"
39
+ },
40
+ "dependencies": {
41
+ "ws": "^8.14.2",
42
+ "uuid": "^9.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^20.10.0",
46
+ "@types/ws": "^8.5.10",
47
+ "@types/uuid": "^9.0.7",
48
+ "typescript": "^5.3.2"
49
+ }
50
+ }