initial actual player count implementation
This commit is contained in:
36
README.md
36
README.md
@@ -341,6 +341,42 @@ For integrating external bots (e.g., for live voting and game notifications), se
|
||||
- Example implementations in Node.js and Go
|
||||
- Security best practices
|
||||
|
||||
## Jackbox Player Count Fetcher
|
||||
|
||||
The `scripts/` directory contains utilities for inspecting Jackbox game lobbies:
|
||||
|
||||
- **[get-player-count.go](scripts/get-player-count.go)** - Go + chromedp script (recommended, most reliable)
|
||||
- **[get-player-count.html](scripts/get-player-count.html)** - Browser-based tool (no installation required!)
|
||||
- **[get-jackbox-player-count.js](scripts/get-jackbox-player-count.js)** - Node.js script (limited, may not work)
|
||||
|
||||
See **[scripts/README.md](scripts/README.md)** for detailed usage instructions.
|
||||
|
||||
### Quick Start
|
||||
|
||||
**Go version (recommended for automation):**
|
||||
```bash
|
||||
cd scripts
|
||||
go run get-player-count.go JYET
|
||||
```
|
||||
|
||||
**Browser version (easiest for manual testing):**
|
||||
1. Open `scripts/get-player-count.html` in any browser
|
||||
2. Enter a 4-letter room code
|
||||
3. View real-time player count and lobby status
|
||||
|
||||
**How it works:**
|
||||
- Automates joining jackbox.tv through Chrome/Chromium
|
||||
- Captures WebSocket messages containing player data
|
||||
- Extracts actual player count from lobby state
|
||||
|
||||
These tools retrieve:
|
||||
- ✅ Actual player count (not just max capacity)
|
||||
- ✅ List of current players and their roles (host/player)
|
||||
- ✅ Game state and lobby status
|
||||
- ✅ Audience count
|
||||
|
||||
**Note:** Direct WebSocket connection is not possible without authentication, so the tools join through jackbox.tv to capture the data.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### games
|
||||
|
||||
230
scripts/README.md
Normal file
230
scripts/README.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Jackbox Player Count Fetcher
|
||||
|
||||
Tools to retrieve the actual player count from a Jackbox game room in real-time.
|
||||
|
||||
## Available Implementations
|
||||
|
||||
### 1. Go + chromedp (Recommended) 🚀
|
||||
The most reliable method - automates joining through jackbox.tv to capture WebSocket data.
|
||||
|
||||
### 2. Browser HTML Interface 🌐
|
||||
Quick visual tool for manual testing - no installation required.
|
||||
|
||||
### 3. Node.js Script (Limited)
|
||||
Attempts direct WebSocket connection - may not work due to authentication requirements.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔍 Automatically joins jackbox.tv to capture WebSocket data
|
||||
- 📊 Returns actual player count (not just max capacity)
|
||||
- 👥 Lists all current players and their roles (host/player)
|
||||
- 🎮 Shows game state, lobby state, and audience count
|
||||
- 🎨 Pretty-printed output with colors
|
||||
|
||||
## Installation
|
||||
|
||||
### Go Version (Recommended)
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
go mod download
|
||||
```
|
||||
|
||||
**Prerequisites:** Go 1.21+ and Chrome/Chromium browser installed
|
||||
|
||||
### Browser Version (No Installation Required!)
|
||||
|
||||
Just open `get-player-count.html` in any web browser - no installation needed!
|
||||
|
||||
### Node.js Version
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
npm install
|
||||
```
|
||||
|
||||
**Note:** The Node.js version may not work reliably due to Jackbox WebSocket authentication requirements.
|
||||
|
||||
## Usage
|
||||
|
||||
### Go Version (Best) 🚀
|
||||
|
||||
```bash
|
||||
# Navigate to scripts directory
|
||||
cd scripts
|
||||
|
||||
# Run the script
|
||||
go run get-player-count.go JYET
|
||||
|
||||
# Or build and run
|
||||
go build -o get-player-count get-player-count.go
|
||||
./get-player-count JYET
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
1. Opens jackbox.tv in headless Chrome
|
||||
2. Automatically enters room code and joins as "Observer"
|
||||
3. Captures WebSocket messages from the browser
|
||||
4. Extracts player count from `client/welcome` message
|
||||
5. Enriches with data from REST API
|
||||
|
||||
### Browser Version 🌐
|
||||
|
||||
```bash
|
||||
# Just open in browser
|
||||
open get-player-count.html
|
||||
```
|
||||
|
||||
1. Open `get-player-count.html` in your web browser
|
||||
2. Enter a room code (e.g., "JYET")
|
||||
3. Click "Get Player Count"
|
||||
4. View results instantly
|
||||
|
||||
This version runs entirely in the browser and doesn't require any backend!
|
||||
|
||||
### Node.js Version (Limited)
|
||||
|
||||
```bash
|
||||
node get-jackbox-player-count.js JYET
|
||||
```
|
||||
|
||||
**Warning:** May fail due to WebSocket authentication requirements. Use the Go version for reliable results.
|
||||
|
||||
### JSON Output (for scripting)
|
||||
|
||||
```bash
|
||||
JSON_OUTPUT=true node get-jackbox-player-count.js JYET
|
||||
```
|
||||
|
||||
### As a Module
|
||||
|
||||
```javascript
|
||||
const { getRoomInfo, getPlayerCount } = require('./get-jackbox-player-count');
|
||||
|
||||
async function example() {
|
||||
const roomCode = 'JYET';
|
||||
|
||||
// Get room info
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
console.log('Game:', roomInfo.appTag);
|
||||
|
||||
// Get player count
|
||||
const result = await getPlayerCount(roomCode, roomInfo);
|
||||
console.log('Players:', result.playerCount);
|
||||
console.log('Player list:', result.players);
|
||||
}
|
||||
|
||||
example();
|
||||
```
|
||||
|
||||
## Output Example
|
||||
|
||||
```
|
||||
Jackbox Player Count Fetcher
|
||||
Room Code: JYET
|
||||
|
||||
Fetching room information...
|
||||
✓ Room found: triviadeath
|
||||
Max Players: 8
|
||||
|
||||
Connecting to WebSocket...
|
||||
URL: wss://i-007fc4f534bce7898.play.jackboxgames.com/api/v2/rooms/JYET
|
||||
|
||||
✓ WebSocket connected
|
||||
|
||||
═══════════════════════════════════════════
|
||||
Jackbox Room Status
|
||||
═══════════════════════════════════════════
|
||||
|
||||
Room Code: JYET
|
||||
Game: triviadeath
|
||||
Game State: Lobby
|
||||
Lobby State: CanStart
|
||||
Locked: No
|
||||
Full: No
|
||||
|
||||
Players: 3 / 8
|
||||
Audience: 0
|
||||
|
||||
Current Players:
|
||||
1. Host (host)
|
||||
2. E (player)
|
||||
3. F (player)
|
||||
|
||||
═══════════════════════════════════════════
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **REST API Call**: First queries `https://ecast.jackboxgames.com/api/v2/rooms/{ROOM_CODE}` to get room metadata
|
||||
2. **WebSocket Connection**: Establishes a WebSocket connection to the game server
|
||||
3. **Join as Observer**: Sends a `client/connect` message to join as an audience member
|
||||
4. **Parse Response**: Listens for the `client/welcome` message containing the full lobby state
|
||||
5. **Extract Player Count**: Counts the players in the `here` object
|
||||
|
||||
## API Response Structure
|
||||
|
||||
The script returns an object with the following structure:
|
||||
|
||||
```javascript
|
||||
{
|
||||
roomCode: "JYET",
|
||||
appTag: "triviadeath",
|
||||
playerCount: 3, // Actual player count
|
||||
audienceCount: 0, // Number of audience members
|
||||
maxPlayers: 8, // Maximum capacity
|
||||
gameState: "Lobby", // Current game state
|
||||
lobbyState: "CanStart", // Whether game can start
|
||||
locked: false, // Whether lobby is locked
|
||||
full: false, // Whether lobby is full
|
||||
players: [ // List of all players
|
||||
{ id: "1", role: "host", name: "Host" },
|
||||
{ id: "2", role: "player", name: "E" },
|
||||
{ id: "3", role: "player", name: "F" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Your App
|
||||
|
||||
You can integrate this into your Jackbox game picker application:
|
||||
|
||||
```javascript
|
||||
// In your backend API
|
||||
const { getRoomInfo, getPlayerCount } = require('./scripts/get-jackbox-player-count');
|
||||
|
||||
app.get('/api/room-status/:roomCode', async (req, res) => {
|
||||
try {
|
||||
const roomCode = req.params.roomCode.toUpperCase();
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
const playerData = await getPlayerCount(roomCode, roomInfo);
|
||||
res.json(playerData);
|
||||
} catch (error) {
|
||||
res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Room not found or invalid"
|
||||
- Double-check the room code is correct
|
||||
- Make sure the game is currently running (room codes expire after games end)
|
||||
|
||||
### "Connection timeout"
|
||||
- The game server might be unavailable
|
||||
- Check your internet connection
|
||||
- The room might have closed
|
||||
|
||||
### WebSocket connection fails
|
||||
- Ensure you have the `ws` package installed: `npm install`
|
||||
- Some networks/firewalls might block WebSocket connections
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `ws` (^8.14.0) - WebSocket client for Node.js
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
184
scripts/TESTING.md
Normal file
184
scripts/TESTING.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Testing the Jackbox Player Count Script
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Go 1.21+ installed
|
||||
2. Chrome or Chromium browser installed
|
||||
3. Active Jackbox lobby with a valid room code
|
||||
|
||||
## Running the Script
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
go run get-player-count.go JYET
|
||||
```
|
||||
|
||||
Replace `JYET` with your actual room code.
|
||||
|
||||
### Debug Mode
|
||||
|
||||
If the script isn't capturing data, run with debug output:
|
||||
|
||||
```bash
|
||||
DEBUG=true go run get-player-count.go JYET
|
||||
```
|
||||
|
||||
This will show:
|
||||
- Each WebSocket frame received
|
||||
- Parsed opcodes
|
||||
- Detailed connection info
|
||||
|
||||
## Expected Output
|
||||
|
||||
### Success
|
||||
|
||||
```
|
||||
🎮 Jackbox Player Count Fetcher
|
||||
Room Code: JYET
|
||||
|
||||
⏳ Navigating to jackbox.tv...
|
||||
✓ Loaded jackbox.tv
|
||||
⏳ Joining room JYET...
|
||||
✓ Clicked Play button, waiting for WebSocket data...
|
||||
✓ Captured lobby data from WebSocket
|
||||
|
||||
═══════════════════════════════════════════
|
||||
Jackbox Room Status
|
||||
═══════════════════════════════════════════
|
||||
|
||||
Room Code: JYET
|
||||
Game: triviadeath
|
||||
Game State: Lobby
|
||||
Lobby State: CanStart
|
||||
Locked: false
|
||||
Full: false
|
||||
|
||||
Players: 3 / 8
|
||||
Audience: 0
|
||||
|
||||
Current Players:
|
||||
1. Host (host)
|
||||
2. E (player)
|
||||
3. F (player)
|
||||
|
||||
═══════════════════════════════════════════
|
||||
```
|
||||
|
||||
### If No WebSocket Messages Captured
|
||||
|
||||
```
|
||||
Error: no WebSocket messages captured - connection may have failed
|
||||
Try running with DEBUG=true for more details
|
||||
```
|
||||
|
||||
**Possible causes:**
|
||||
- Room code is invalid
|
||||
- Game lobby is closed
|
||||
- Network connectivity issues
|
||||
|
||||
### If Messages Captured but No Player Data
|
||||
|
||||
```
|
||||
⚠️ Captured 15 WebSocket messages but couldn't find 'client/welcome'
|
||||
|
||||
Message types found:
|
||||
- room/update: 5
|
||||
- audience/count: 3
|
||||
- ping: 7
|
||||
|
||||
Error: could not find player count data in WebSocket messages
|
||||
Room may be invalid, closed, or not in lobby state
|
||||
```
|
||||
|
||||
**Possible causes:**
|
||||
- Game has already started (not in lobby)
|
||||
- Room code exists but game is in different state
|
||||
- WebSocket connected but welcome message not sent
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cookie Errors (Harmless)
|
||||
|
||||
You may see errors like:
|
||||
```
|
||||
ERROR: could not unmarshal event: parse error: expected string near offset 1247 of 'cookiePart...'
|
||||
```
|
||||
|
||||
**These are harmless** and are suppressed in the output. They're chromedp trying to parse cookie data that doesn't follow expected format.
|
||||
|
||||
### "Failed to load jackbox.tv"
|
||||
|
||||
- Check your internet connection
|
||||
- Verify https://jackbox.tv loads in a regular browser
|
||||
- Try running without `headless` mode (edit the Go file)
|
||||
|
||||
### "Failed to enter room code"
|
||||
|
||||
- Verify the room code is valid
|
||||
- Check that the lobby is actually open
|
||||
- Try running with DEBUG=true to see what's happening
|
||||
|
||||
### "Failed to click Play button"
|
||||
|
||||
- The button may still be disabled
|
||||
- Room code validation may have failed
|
||||
- Name field may not be filled
|
||||
|
||||
### No WebSocket Messages at All
|
||||
|
||||
This means the browser never connected to the game's WebSocket server:
|
||||
- Verify the room code is correct
|
||||
- Check that the game lobby is actually open and accepting players
|
||||
- The game may have a full lobby
|
||||
- The room may have expired
|
||||
|
||||
## Testing with Different Game States
|
||||
|
||||
### Lobby (Should Work)
|
||||
When the game is in the lobby waiting for players to join.
|
||||
|
||||
### During Game (May Not Work)
|
||||
Once the game starts, the WebSocket messages change. The `client/welcome` message may not be sent.
|
||||
|
||||
### After Game (Won't Work)
|
||||
Room codes expire after the game session ends.
|
||||
|
||||
## Manual Verification
|
||||
|
||||
You can verify the data by:
|
||||
|
||||
1. Open https://jackbox.tv in a regular browser
|
||||
2. Open Developer Tools (F12)
|
||||
3. Go to Network tab
|
||||
4. Filter by "WS" (WebSocket)
|
||||
5. Join the room with the same code
|
||||
6. Look for `client/welcome` message in WebSocket frames
|
||||
7. Compare the data with what the script outputs
|
||||
|
||||
## Common Room States
|
||||
|
||||
| State | `client/welcome` | Player Count Available |
|
||||
|-------|------------------|------------------------|
|
||||
| Lobby - Waiting | ✅ Yes | ✅ Yes |
|
||||
| Lobby - Full | ✅ Yes | ✅ Yes |
|
||||
| Game Starting | ⚠️ Maybe | ⚠️ Maybe |
|
||||
| Game In Progress | ❌ No | ❌ No |
|
||||
| Game Ended | ❌ No | ❌ No |
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- Takes ~5-10 seconds to complete
|
||||
- Most time is waiting for WebSocket connection
|
||||
- Headless Chrome startup adds ~1-2 seconds
|
||||
- Network latency affects timing
|
||||
|
||||
## Next Steps
|
||||
|
||||
If the script works:
|
||||
1. Extract the function into a library package
|
||||
2. Integrate with your bot
|
||||
3. Set up cron jobs or periodic polling
|
||||
4. Add result caching to reduce load
|
||||
|
||||
262
scripts/get-jackbox-player-count.js
Normal file
262
scripts/get-jackbox-player-count.js
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Jackbox Player Count Fetcher
|
||||
*
|
||||
* This script connects to a Jackbox game room and retrieves the actual player count
|
||||
* by establishing a WebSocket connection and listening for game state updates.
|
||||
*
|
||||
* Usage:
|
||||
* node get-jackbox-player-count.js <ROOM_CODE>
|
||||
* node get-jackbox-player-count.js JYET
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
|
||||
// Try to load ws from multiple locations
|
||||
let WebSocket;
|
||||
try {
|
||||
WebSocket = require('ws');
|
||||
} catch (e) {
|
||||
try {
|
||||
WebSocket = require('../backend/node_modules/ws');
|
||||
} catch (e2) {
|
||||
console.error('Error: WebSocket library (ws) not found.');
|
||||
console.error('Please run: npm install ws');
|
||||
console.error('Or run this script from the backend directory where ws is already installed.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ANSI color codes for pretty output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
red: '\x1b[31m',
|
||||
cyan: '\x1b[36m'
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches room information from the Jackbox REST API
|
||||
*/
|
||||
async function getRoomInfo(roomCode) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`;
|
||||
|
||||
https.get(url, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.ok) {
|
||||
resolve(json.body);
|
||||
} else {
|
||||
reject(new Error('Room not found or invalid'));
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the Jackbox WebSocket and retrieves player count
|
||||
* Note: Direct WebSocket connection requires proper authentication flow.
|
||||
* This uses the ecast endpoint which is designed for external connections.
|
||||
*/
|
||||
async function getPlayerCount(roomCode, roomInfo) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Use the audienceHost (ecast) instead of direct game host
|
||||
const wsUrl = `wss://${roomInfo.audienceHost}/api/v2/audience/${roomCode}/play`;
|
||||
|
||||
console.log(`${colors.blue}Connecting to WebSocket...${colors.reset}`);
|
||||
console.log(`${colors.cyan}URL: ${wsUrl}${colors.reset}\n`);
|
||||
|
||||
const ws = new WebSocket(wsUrl, {
|
||||
headers: {
|
||||
'Origin': 'https://jackbox.tv',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
let timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error('Connection timeout - room may be closed or unreachable'));
|
||||
}, 15000); // 15 second timeout
|
||||
|
||||
let receivedAnyData = false;
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log(`${colors.green}✓ WebSocket connected${colors.reset}\n`);
|
||||
|
||||
// For audience endpoint, we might not need to send join message
|
||||
// Just listen for messages
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
receivedAnyData = true;
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
console.log(`${colors.yellow}Received message:${colors.reset}`, message.opcode || 'unknown');
|
||||
|
||||
// Look for various message types that might contain player info
|
||||
if (message.opcode === 'client/welcome' && message.result) {
|
||||
clearTimeout(timeout);
|
||||
|
||||
const here = message.result.here || {};
|
||||
const playerCount = Object.keys(here).length;
|
||||
const audienceCount = message.result.entities?.audience?.[1]?.count || 0;
|
||||
const lobbyState = message.result.entities?.['bc:room']?.[1]?.val?.lobbyState || 'Unknown';
|
||||
const gameState = message.result.entities?.['bc:room']?.[1]?.val?.state || 'Unknown';
|
||||
|
||||
// Extract player details
|
||||
const players = [];
|
||||
for (const [id, playerData] of Object.entries(here)) {
|
||||
const roles = playerData.roles || {};
|
||||
if (roles.host) {
|
||||
players.push({ id, role: 'host', name: 'Host' });
|
||||
} else if (roles.player) {
|
||||
players.push({ id, role: 'player', name: roles.player.name || 'Unknown' });
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
roomCode,
|
||||
appTag: roomInfo.appTag,
|
||||
playerCount,
|
||||
audienceCount,
|
||||
maxPlayers: roomInfo.maxPlayers,
|
||||
gameState,
|
||||
lobbyState,
|
||||
locked: roomInfo.locked,
|
||||
full: roomInfo.full,
|
||||
players
|
||||
};
|
||||
|
||||
ws.close();
|
||||
resolve(result);
|
||||
} else if (message.opcode === 'room/count' || message.opcode === 'audience/count-group') {
|
||||
// Audience count updates
|
||||
console.log(`${colors.cyan}Audience count message received${colors.reset}`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors, might be non-JSON messages
|
||||
console.log(`${colors.yellow}Parse error:${colors.reset}`, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`WebSocket error: ${error.message}\n\n` +
|
||||
`${colors.yellow}Note:${colors.reset} Direct WebSocket access requires joining through jackbox.tv.\n` +
|
||||
`This limitation means we cannot directly query player count without joining the game.`));
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
if (!receivedAnyData) {
|
||||
reject(new Error('WebSocket closed without receiving data.\n\n' +
|
||||
`${colors.yellow}Note:${colors.reset} The Jackbox WebSocket API requires authentication that's only\n` +
|
||||
`available when joining through the official jackbox.tv interface.\n\n` +
|
||||
`${colors.cyan}Alternative:${colors.reset} Use the REST API to check if room is full, or join\n` +
|
||||
`through jackbox.tv in a browser to get real-time player counts.`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty prints the results
|
||||
*/
|
||||
function printResults(result) {
|
||||
console.log(`${colors.bright}═══════════════════════════════════════════${colors.reset}`);
|
||||
console.log(`${colors.bright} Jackbox Room Status${colors.reset}`);
|
||||
console.log(`${colors.bright}═══════════════════════════════════════════${colors.reset}\n`);
|
||||
|
||||
console.log(`${colors.cyan}Room Code:${colors.reset} ${result.roomCode}`);
|
||||
console.log(`${colors.cyan}Game:${colors.reset} ${result.appTag}`);
|
||||
console.log(`${colors.cyan}Game State:${colors.reset} ${result.gameState}`);
|
||||
console.log(`${colors.cyan}Lobby State:${colors.reset} ${result.lobbyState}`);
|
||||
console.log(`${colors.cyan}Locked:${colors.reset} ${result.locked ? 'Yes' : 'No'}`);
|
||||
console.log(`${colors.cyan}Full:${colors.reset} ${result.full ? 'Yes' : 'No'}`);
|
||||
console.log();
|
||||
|
||||
console.log(`${colors.bright}${colors.green}Players:${colors.reset} ${colors.bright}${result.playerCount}${colors.reset} / ${result.maxPlayers}`);
|
||||
console.log(`${colors.cyan}Audience:${colors.reset} ${result.audienceCount}`);
|
||||
console.log();
|
||||
|
||||
if (result.players.length > 0) {
|
||||
console.log(`${colors.bright}Current Players:${colors.reset}`);
|
||||
result.players.forEach((player, idx) => {
|
||||
const roleColor = player.role === 'host' ? colors.yellow : colors.green;
|
||||
console.log(` ${idx + 1}. ${roleColor}${player.name}${colors.reset} (${player.role})`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n${colors.bright}═══════════════════════════════════════════${colors.reset}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error(`${colors.red}Error: Room code required${colors.reset}`);
|
||||
console.log(`\nUsage: node get-jackbox-player-count.js <ROOM_CODE>`);
|
||||
console.log(`Example: node get-jackbox-player-count.js JYET\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const roomCode = args[0].toUpperCase();
|
||||
|
||||
console.log(`${colors.bright}Jackbox Player Count Fetcher${colors.reset}`);
|
||||
console.log(`${colors.cyan}Room Code: ${roomCode}${colors.reset}\n`);
|
||||
|
||||
try {
|
||||
// Step 1: Get room info from REST API
|
||||
console.log(`${colors.blue}Fetching room information...${colors.reset}`);
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
console.log(`${colors.green}✓ Room found: ${roomInfo.appTag}${colors.reset}`);
|
||||
console.log(`${colors.cyan} Max Players: ${roomInfo.maxPlayers}${colors.reset}\n`);
|
||||
|
||||
// Step 2: Connect to WebSocket and get player count
|
||||
const result = await getPlayerCount(roomCode, roomInfo);
|
||||
|
||||
// Step 3: Print results
|
||||
printResults(result);
|
||||
|
||||
// Return just the player count for scripting purposes
|
||||
if (process.env.JSON_OUTPUT === 'true') {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error(`${colors.red}Error: ${error.message}${colors.reset}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
// Export for use as a module
|
||||
module.exports = {
|
||||
getRoomInfo,
|
||||
getPlayerCount
|
||||
};
|
||||
|
||||
394
scripts/get-player-count.go
Normal file
394
scripts/get-player-count.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
// PlayerInfo represents a player in the lobby
|
||||
type PlayerInfo struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// LobbyStatus contains all information about the current lobby
|
||||
type LobbyStatus struct {
|
||||
RoomCode string `json:"roomCode"`
|
||||
AppTag string `json:"appTag"`
|
||||
PlayerCount int `json:"playerCount"`
|
||||
AudienceCount int `json:"audienceCount"`
|
||||
MaxPlayers int `json:"maxPlayers"`
|
||||
GameState string `json:"gameState"`
|
||||
LobbyState string `json:"lobbyState"`
|
||||
Locked bool `json:"locked"`
|
||||
Full bool `json:"full"`
|
||||
Players []PlayerInfo `json:"players"`
|
||||
}
|
||||
|
||||
// WebSocketMessage represents a parsed WebSocket message
|
||||
type WebSocketMessage struct {
|
||||
PC int `json:"pc"`
|
||||
Opcode string `json:"opcode"`
|
||||
Result map[string]interface{} `json:"result"`
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: go run get-player-count.go <ROOM_CODE>")
|
||||
fmt.Println("Example: go run get-player-count.go JYET")
|
||||
fmt.Println("\nSet DEBUG=true for verbose output:")
|
||||
fmt.Println("DEBUG=true go run get-player-count.go JYET")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
roomCode := strings.ToUpper(strings.TrimSpace(os.Args[1]))
|
||||
|
||||
if len(roomCode) != 4 {
|
||||
fmt.Println("Error: Room code must be exactly 4 characters")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("🎮 Jackbox Player Count Fetcher\n")
|
||||
fmt.Printf("Room Code: %s\n\n", roomCode)
|
||||
|
||||
status, err := getPlayerCount(roomCode)
|
||||
if err != nil {
|
||||
log.Fatalf("Error: %v\n", err)
|
||||
}
|
||||
|
||||
printStatus(status)
|
||||
}
|
||||
|
||||
func getPlayerCount(roomCode string) (*LobbyStatus, error) {
|
||||
// Create chrome context with less verbose logging
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-web-security", true),
|
||||
)
|
||||
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
|
||||
// Create context without default logging to reduce cookie errors
|
||||
ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(func(s string, i ...interface{}) {
|
||||
// Only log non-cookie errors
|
||||
msg := fmt.Sprintf(s, i...)
|
||||
if !strings.Contains(msg, "cookiePart") && !strings.Contains(msg, "could not unmarshal") {
|
||||
log.Printf(msg)
|
||||
}
|
||||
}))
|
||||
defer cancel()
|
||||
|
||||
// Set timeout
|
||||
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var lobbyStatus *LobbyStatus
|
||||
welcomeMessageFound := false
|
||||
wsMessages := make([]string, 0)
|
||||
|
||||
// Listen for WebSocket frames - this is the most reliable method
|
||||
debugMode := os.Getenv("DEBUG") == "true"
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] Event type: %T\n", ev)
|
||||
}
|
||||
|
||||
switch ev := ev.(type) {
|
||||
case *network.EventWebSocketCreated:
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] WebSocket Created: %s\n", ev.URL)
|
||||
}
|
||||
case *network.EventWebSocketFrameReceived:
|
||||
// Capture all WebSocket frames
|
||||
wsMessages = append(wsMessages, ev.Response.PayloadData)
|
||||
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] WS Frame received (%d bytes)\n", len(ev.Response.PayloadData))
|
||||
if len(ev.Response.PayloadData) < 200 {
|
||||
fmt.Printf("[DEBUG] Data: %s\n", ev.Response.PayloadData)
|
||||
} else {
|
||||
fmt.Printf("[DEBUG] Data (truncated): %s...\n", ev.Response.PayloadData[:200])
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse immediately
|
||||
var wsMsg WebSocketMessage
|
||||
if err := json.Unmarshal([]byte(ev.Response.PayloadData), &wsMsg); err == nil {
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] Parsed opcode: %s\n", wsMsg.Opcode)
|
||||
}
|
||||
|
||||
if wsMsg.Opcode == "client/welcome" && wsMsg.Result != nil {
|
||||
lobbyStatus = parseWelcomeMessage(&wsMsg)
|
||||
welcomeMessageFound = true
|
||||
fmt.Println("✓ Captured lobby data from WebSocket")
|
||||
}
|
||||
} else if debugMode {
|
||||
fmt.Printf("[DEBUG] Failed to parse JSON: %v\n", err)
|
||||
}
|
||||
case *network.EventWebSocketFrameSent:
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] WS Frame sent: %s\n", ev.Response.PayloadData)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fmt.Println("⏳ Navigating to jackbox.tv...")
|
||||
|
||||
// Enable network tracking BEFORE navigation
|
||||
if err := chromedp.Run(ctx, network.Enable()); err != nil {
|
||||
return nil, fmt.Errorf("failed to enable network tracking: %w", err)
|
||||
}
|
||||
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.Navigate("https://jackbox.tv/"),
|
||||
chromedp.WaitVisible(`input[placeholder*="ENTER 4-LETTER CODE"]`, chromedp.ByQuery),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load jackbox.tv: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Loaded jackbox.tv\n")
|
||||
fmt.Printf("⏳ Joining room %s...\n", roomCode)
|
||||
|
||||
// Type room code and press Enter to join
|
||||
err = chromedp.Run(ctx,
|
||||
chromedp.Focus(`input[placeholder*="ENTER 4-LETTER CODE"]`, chromedp.ByQuery),
|
||||
chromedp.SendKeys(`input[placeholder*="ENTER 4-LETTER CODE"]`, roomCode+"\n", chromedp.ByQuery),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to enter room code: %w", err)
|
||||
}
|
||||
|
||||
if debugMode {
|
||||
fmt.Println("[DEBUG] Entered room code and pressed Enter")
|
||||
}
|
||||
|
||||
// Wait for room code validation and page transition
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
fmt.Println("✓ Clicked Play button, waiting for WebSocket data...")
|
||||
|
||||
// Check if we successfully joined (look for typical lobby UI elements)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
var pageText string
|
||||
err = chromedp.Run(ctx,
|
||||
chromedp.Text("body", &pageText, chromedp.ByQuery),
|
||||
)
|
||||
if err == nil && debugMode {
|
||||
if strings.Contains(pageText, "Sit back") || strings.Contains(pageText, "waiting") {
|
||||
fmt.Println("[DEBUG] Successfully joined lobby (found lobby text)")
|
||||
} else {
|
||||
fmt.Printf("[DEBUG] Page text: %s\n", pageText[:min(300, len(pageText))])
|
||||
}
|
||||
}
|
||||
|
||||
// Wait longer for WebSocket to connect and receive welcome message
|
||||
for i := 0; i < 15 && !welcomeMessageFound; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if i%4 == 0 {
|
||||
fmt.Printf("⏳ Waiting for lobby data... (%ds)\n", i/2)
|
||||
}
|
||||
}
|
||||
|
||||
// If we still didn't get it from WebSocket frames, try parsing all captured messages
|
||||
if !welcomeMessageFound && len(wsMessages) > 0 {
|
||||
fmt.Printf("⏳ Parsing %d captured WebSocket messages...\n", len(wsMessages))
|
||||
|
||||
for _, msg := range wsMessages {
|
||||
var wsMsg WebSocketMessage
|
||||
if err := json.Unmarshal([]byte(msg), &wsMsg); err == nil {
|
||||
if wsMsg.Opcode == "client/welcome" && wsMsg.Result != nil {
|
||||
lobbyStatus = parseWelcomeMessage(&wsMsg)
|
||||
welcomeMessageFound = true
|
||||
fmt.Println("✓ Found lobby data in captured messages")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lobbyStatus == nil {
|
||||
if len(wsMessages) == 0 {
|
||||
return nil, fmt.Errorf("no WebSocket messages captured - connection may have failed\nTry running with DEBUG=true for more details")
|
||||
}
|
||||
|
||||
// Show what we captured
|
||||
fmt.Printf("\n⚠️ Captured %d WebSocket messages but couldn't find 'client/welcome'\n", len(wsMessages))
|
||||
fmt.Println("\nMessage types found:")
|
||||
opcodes := make(map[string]int)
|
||||
for _, msg := range wsMessages {
|
||||
var wsMsg WebSocketMessage
|
||||
if err := json.Unmarshal([]byte(msg), &wsMsg); err == nil {
|
||||
opcodes[wsMsg.Opcode]++
|
||||
}
|
||||
}
|
||||
for opcode, count := range opcodes {
|
||||
fmt.Printf(" - %s: %d\n", opcode, count)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("could not find player count data in WebSocket messages\nRoom may be invalid, closed, or not in lobby state")
|
||||
}
|
||||
|
||||
lobbyStatus.RoomCode = roomCode
|
||||
|
||||
// Fetch additional room info from REST API
|
||||
if err := enrichWithRestAPI(lobbyStatus); err != nil {
|
||||
fmt.Printf("Warning: Could not fetch additional room info: %v\n", err)
|
||||
}
|
||||
|
||||
return lobbyStatus, nil
|
||||
}
|
||||
|
||||
func parseWelcomeMessage(msg *WebSocketMessage) *LobbyStatus {
|
||||
status := &LobbyStatus{
|
||||
Players: []PlayerInfo{},
|
||||
}
|
||||
|
||||
// Parse "here" object for players
|
||||
if here, ok := msg.Result["here"].(map[string]interface{}); ok {
|
||||
status.PlayerCount = len(here)
|
||||
|
||||
for id, playerData := range here {
|
||||
if pd, ok := playerData.(map[string]interface{}); ok {
|
||||
player := PlayerInfo{ID: id}
|
||||
|
||||
if roles, ok := pd["roles"].(map[string]interface{}); ok {
|
||||
if _, hasHost := roles["host"]; hasHost {
|
||||
player.Role = "host"
|
||||
player.Name = "Host"
|
||||
} else if playerRole, ok := roles["player"].(map[string]interface{}); ok {
|
||||
player.Role = "player"
|
||||
if name, ok := playerRole["name"].(string); ok {
|
||||
player.Name = name
|
||||
} else {
|
||||
player.Name = "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status.Players = append(status.Players, player)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse entities for additional info
|
||||
if entities, ok := msg.Result["entities"].(map[string]interface{}); ok {
|
||||
// Audience count
|
||||
if audience, ok := entities["audience"].([]interface{}); ok && len(audience) > 1 {
|
||||
if audienceData, ok := audience[1].(map[string]interface{}); ok {
|
||||
if count, ok := audienceData["count"].(float64); ok {
|
||||
status.AudienceCount = int(count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Room state
|
||||
if bcRoom, ok := entities["bc:room"].([]interface{}); ok && len(bcRoom) > 1 {
|
||||
if roomData, ok := bcRoom[1].(map[string]interface{}); ok {
|
||||
if val, ok := roomData["val"].(map[string]interface{}); ok {
|
||||
if gameState, ok := val["state"].(string); ok {
|
||||
status.GameState = gameState
|
||||
}
|
||||
if lobbyState, ok := val["lobbyState"].(string); ok {
|
||||
status.LobbyState = lobbyState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func enrichWithRestAPI(status *LobbyStatus) error {
|
||||
// Fetch additional room info from REST API
|
||||
url := fmt.Sprintf("https://ecast.jackboxgames.com/api/v2/rooms/%s", status.RoomCode)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
Body struct {
|
||||
AppTag string `json:"appTag"`
|
||||
MaxPlayers int `json:"maxPlayers"`
|
||||
Locked bool `json:"locked"`
|
||||
Full bool `json:"full"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.OK {
|
||||
status.AppTag = result.Body.AppTag
|
||||
status.MaxPlayers = result.Body.MaxPlayers
|
||||
status.Locked = result.Body.Locked
|
||||
status.Full = result.Body.Full
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printStatus(status *LobbyStatus) {
|
||||
fmt.Println()
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
fmt.Println(" Jackbox Room Status")
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
fmt.Println()
|
||||
fmt.Printf("Room Code: %s\n", status.RoomCode)
|
||||
fmt.Printf("Game: %s\n", status.AppTag)
|
||||
fmt.Printf("Game State: %s\n", status.GameState)
|
||||
fmt.Printf("Lobby State: %s\n", status.LobbyState)
|
||||
fmt.Printf("Locked: %t\n", status.Locked)
|
||||
fmt.Printf("Full: %t\n", status.Full)
|
||||
fmt.Println()
|
||||
fmt.Printf("Players: %d / %d\n", status.PlayerCount, status.MaxPlayers)
|
||||
fmt.Printf("Audience: %d\n", status.AudienceCount)
|
||||
fmt.Println()
|
||||
|
||||
if len(status.Players) > 0 {
|
||||
fmt.Println("Current Players:")
|
||||
for i, player := range status.Players {
|
||||
fmt.Printf(" %d. %s (%s)\n", i+1, player.Name, player.Role)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
468
scripts/get-player-count.html
Normal file
468
scripts/get-player-count.html
Normal file
@@ -0,0 +1,468 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Jackbox Player Count Fetcher</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 6px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status.loading {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.results.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-value.highlight {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.players-list {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.players-list h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.player-item {
|
||||
padding: 10px;
|
||||
margin: 5px 0;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.player-role {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.player-role.host {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.player-role.player {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge.success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.badge.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎮 Jackbox Player Count Fetcher</h1>
|
||||
<p class="subtitle">Enter a room code to get real-time player information</p>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="text" id="roomCode" placeholder="Enter room code (e.g., JYET)" maxlength="4">
|
||||
<button id="fetchBtn" onclick="fetchPlayerCount()">Get Player Count</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div id="results" class="results">
|
||||
<div class="result-card">
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Players</div>
|
||||
<div class="stat-value highlight" id="playerCount">-</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Max Players</div>
|
||||
<div class="stat-value" id="maxPlayers">-</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Audience</div>
|
||||
<div class="stat-value" id="audienceCount">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Room Code:</span>
|
||||
<span class="info-value" id="displayRoomCode">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Game:</span>
|
||||
<span class="info-value" id="gameTag">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Game State:</span>
|
||||
<span class="info-value" id="gameState">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Lobby State:</span>
|
||||
<span class="info-value" id="lobbyState">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Locked:</span>
|
||||
<span class="info-value" id="locked">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Full:</span>
|
||||
<span class="info-value" id="full">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playersContainer" class="players-list" style="display: none;">
|
||||
<h3>Current Players</h3>
|
||||
<div id="playersList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Allow Enter key to trigger fetch
|
||||
document.getElementById('roomCode').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
fetchPlayerCount();
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchPlayerCount() {
|
||||
const roomCode = document.getElementById('roomCode').value.toUpperCase().trim();
|
||||
|
||||
if (!roomCode || roomCode.length !== 4) {
|
||||
showStatus('Please enter a valid 4-letter room code', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('Fetching room information...', 'loading');
|
||||
document.getElementById('fetchBtn').disabled = true;
|
||||
document.getElementById('results').classList.remove('visible');
|
||||
|
||||
try {
|
||||
// Step 1: Get room info
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
|
||||
showStatus('Connecting to WebSocket...', 'loading');
|
||||
|
||||
// Step 2: Connect to WebSocket and get player count
|
||||
const result = await getPlayerCount(roomCode, roomInfo);
|
||||
|
||||
showStatus('✓ Successfully retrieved player information', 'success');
|
||||
displayResults(result);
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('status').style.display = 'none';
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
showStatus(`Error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
document.getElementById('fetchBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoomInfo(roomCode) {
|
||||
const response = await fetch(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error('Room not found or invalid');
|
||||
}
|
||||
|
||||
return data.body;
|
||||
}
|
||||
|
||||
async function getPlayerCount(roomCode, roomInfo) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = `wss://${roomInfo.host}/api/v2/rooms/${roomCode}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error('Connection timeout'));
|
||||
}, 10000);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Join as audience to get lobby state without affecting the game
|
||||
ws.send(JSON.stringify({
|
||||
opcode: 'client/connect',
|
||||
params: {
|
||||
name: 'WebObserver',
|
||||
role: 'audience',
|
||||
format: 'json'
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.opcode === 'client/welcome' && message.result) {
|
||||
clearTimeout(timeout);
|
||||
|
||||
const here = message.result.here || {};
|
||||
const playerCount = Object.keys(here).length;
|
||||
const audienceCount = message.result.entities?.audience?.[1]?.count || 0;
|
||||
const lobbyState = message.result.entities?.['bc:room']?.[1]?.val?.lobbyState || 'Unknown';
|
||||
const gameState = message.result.entities?.['bc:room']?.[1]?.val?.state || 'Unknown';
|
||||
|
||||
const players = [];
|
||||
for (const [id, playerData] of Object.entries(here)) {
|
||||
const roles = playerData.roles || {};
|
||||
if (roles.host) {
|
||||
players.push({ id, role: 'host', name: 'Host' });
|
||||
} else if (roles.player) {
|
||||
players.push({ id, role: 'player', name: roles.player.name || 'Unknown' });
|
||||
}
|
||||
}
|
||||
|
||||
ws.close();
|
||||
resolve({
|
||||
roomCode,
|
||||
appTag: roomInfo.appTag,
|
||||
playerCount,
|
||||
audienceCount,
|
||||
maxPlayers: roomInfo.maxPlayers,
|
||||
gameState,
|
||||
lobbyState,
|
||||
locked: roomInfo.locked,
|
||||
full: roomInfo.full,
|
||||
players
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = message;
|
||||
status.className = `status ${type}`;
|
||||
}
|
||||
|
||||
function displayResults(result) {
|
||||
document.getElementById('results').classList.add('visible');
|
||||
|
||||
document.getElementById('playerCount').textContent = result.playerCount;
|
||||
document.getElementById('maxPlayers').textContent = result.maxPlayers;
|
||||
document.getElementById('audienceCount').textContent = result.audienceCount;
|
||||
document.getElementById('displayRoomCode').textContent = result.roomCode;
|
||||
document.getElementById('gameTag').textContent = result.appTag;
|
||||
document.getElementById('gameState').textContent = result.gameState;
|
||||
document.getElementById('lobbyState').textContent = result.lobbyState;
|
||||
|
||||
document.getElementById('locked').innerHTML = result.locked
|
||||
? '<span class="badge error">Yes</span>'
|
||||
: '<span class="badge success">No</span>';
|
||||
|
||||
document.getElementById('full').innerHTML = result.full
|
||||
? '<span class="badge error">Yes</span>'
|
||||
: '<span class="badge success">No</span>';
|
||||
|
||||
if (result.players && result.players.length > 0) {
|
||||
const playersList = document.getElementById('playersList');
|
||||
playersList.innerHTML = result.players.map(player => `
|
||||
<div class="player-item">
|
||||
<span class="player-name">${player.name}</span>
|
||||
<span class="player-role ${player.role}">${player.role}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('playersContainer').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('playersContainer').style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
18
scripts/go.mod
Normal file
18
scripts/go.mod
Normal file
@@ -0,0 +1,18 @@
|
||||
module jackbox-player-count
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998
|
||||
github.com/chromedp/chromedp v0.9.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.3.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
)
|
||||
23
scripts/go.sum
Normal file
23
scripts/go.sum
Normal file
@@ -0,0 +1,23 @@
|
||||
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998 h1:2zipcnjfFdqAjOQa8otCCh0Lk1M7RBzciy3s80YAKHk=
|
||||
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.9.3 h1:Wq58e0dZOdHsxaj9Owmfcf+ibtpYN1N0FWVbaxa/esg=
|
||||
github.com/chromedp/chromedp v0.9.3/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0=
|
||||
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
184
scripts/jackbox-count-v2.js
Normal file
184
scripts/jackbox-count-v2.js
Normal file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
async function getPlayerCount(roomCode) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-web-security']
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
let playerCount = null;
|
||||
|
||||
// Listen for console API calls (this catches console.log with formatting)
|
||||
page.on('console', async msg => {
|
||||
try {
|
||||
// Get all args
|
||||
for (const arg of msg.args()) {
|
||||
const val = await arg.jsonValue();
|
||||
const str = JSON.stringify(val);
|
||||
|
||||
if (process.env.DEBUG) console.error('[CONSOLE]', str.substring(0, 200));
|
||||
|
||||
// Check if this is the welcome message
|
||||
if (str && str.includes('"opcode":"client/welcome"') && str.includes('"here"')) {
|
||||
const data = JSON.parse(str);
|
||||
if (data.result && data.result.here) {
|
||||
playerCount = Object.keys(data.result.here).length;
|
||||
if (process.env.DEBUG) console.error('[FOUND] Player count:', playerCount);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
if (process.env.DEBUG) console.error('[1] Loading page...');
|
||||
await page.goto('https://jackbox.tv/', { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
|
||||
// Clear storage and reload to avoid reconnect
|
||||
if (process.env.DEBUG) console.error('[2] Clearing storage...');
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
await page.reload({ waitUntil: 'networkidle2' });
|
||||
|
||||
await page.waitForSelector('input[placeholder*="ENTER 4-LETTER CODE"]', { timeout: 10000 });
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[2.5] Checking all inputs on page...');
|
||||
const allInputs = await page.evaluate(() => {
|
||||
const inputs = Array.from(document.querySelectorAll('input'));
|
||||
return inputs.map(inp => ({
|
||||
type: inp.type,
|
||||
placeholder: inp.placeholder,
|
||||
value: inp.value,
|
||||
name: inp.name,
|
||||
id: inp.id,
|
||||
visible: inp.offsetParent !== null
|
||||
}));
|
||||
});
|
||||
console.error('[2.5] All inputs:', JSON.stringify(allInputs, null, 2));
|
||||
}
|
||||
|
||||
if (process.env.DEBUG) console.error('[3] Typing room code...');
|
||||
|
||||
// Type room code character by character (with delay to trigger validation)
|
||||
await page.click('input[placeholder*="ENTER 4-LETTER CODE"]');
|
||||
await page.type('input[placeholder*="ENTER 4-LETTER CODE"]', roomCode, { delay: 100 });
|
||||
|
||||
// Wait for room validation to complete (look for loader success message)
|
||||
if (process.env.DEBUG) console.error('[3.5] Waiting for room validation...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Type name character by character - this will enable the Play button
|
||||
if (process.env.DEBUG) console.error('[3.6] Typing name...');
|
||||
await page.click('input[placeholder*="ENTER YOUR NAME"]');
|
||||
await page.type('input[placeholder*="ENTER YOUR NAME"]', 'Observer', { delay: 100 });
|
||||
|
||||
// Wait a moment for button to fully enable
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
const fieldValues = await page.evaluate(() => {
|
||||
const roomInput = document.querySelector('input[placeholder*="ENTER 4-LETTER CODE"]');
|
||||
const nameInput = document.querySelector('input[placeholder*="ENTER YOUR NAME"]');
|
||||
return {
|
||||
roomCode: roomInput ? roomInput.value : 'NOT FOUND',
|
||||
name: nameInput ? nameInput.value : 'NOT FOUND'
|
||||
};
|
||||
});
|
||||
console.error('[3.5] Field values:', fieldValues);
|
||||
}
|
||||
|
||||
// Find the Play or RECONNECT button (case-insensitive, not disabled)
|
||||
if (process.env.DEBUG) {
|
||||
const buttonInfo = await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const allButtons = buttons.map(b => ({
|
||||
text: b.textContent.trim(),
|
||||
disabled: b.disabled,
|
||||
visible: b.offsetParent !== null
|
||||
}));
|
||||
const actionBtn = buttons.find(b => {
|
||||
const text = b.textContent.toUpperCase();
|
||||
return (text.includes('PLAY') || text.includes('RECONNECT')) && !b.disabled;
|
||||
});
|
||||
return {
|
||||
allButtons,
|
||||
found: actionBtn ? actionBtn.textContent.trim() : 'NOT FOUND'
|
||||
};
|
||||
});
|
||||
console.error('[4] All buttons:', JSON.stringify(buttonInfo.allButtons, null, 2));
|
||||
console.error('[4] Target button:', buttonInfo.found);
|
||||
}
|
||||
|
||||
if (process.env.DEBUG) console.error('[5] Clicking Play/Reconnect (even if disabled)...');
|
||||
await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const actionBtn = buttons.find(b => {
|
||||
const text = b.textContent.toUpperCase();
|
||||
return text.includes('PLAY') || text.includes('RECONNECT');
|
||||
});
|
||||
if (actionBtn) {
|
||||
// Remove disabled attribute and click
|
||||
actionBtn.disabled = false;
|
||||
actionBtn.click();
|
||||
} else {
|
||||
throw new Error('Could not find PLAY or RECONNECT button');
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for navigation/lobby to load
|
||||
if (process.env.DEBUG) console.error('[6] Waiting for lobby (5 seconds)...');
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// Check if we're in the lobby
|
||||
const pageText = await page.evaluate(() => document.body.innerText);
|
||||
const inLobby = pageText.includes('Sit back') || pageText.includes('relax');
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[7] In lobby:', inLobby);
|
||||
console.error('[7] Page text sample:', pageText.substring(0, 100));
|
||||
console.error('[7] Page URL:', page.url());
|
||||
}
|
||||
|
||||
// Wait for WebSocket message
|
||||
if (process.env.DEBUG) console.error('[8] Waiting for player count...');
|
||||
for (let i = 0; i < 20 && playerCount === null; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
if (process.env.DEBUG && i % 4 === 0) {
|
||||
console.error(`[8.${i}] Still waiting...`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
if (playerCount === null) {
|
||||
throw new Error('Could not get player count');
|
||||
}
|
||||
|
||||
return playerCount;
|
||||
}
|
||||
|
||||
const roomCode = process.argv[2];
|
||||
if (!roomCode) {
|
||||
console.error('Usage: node jackbox-count-v2.js <ROOM_CODE>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
getPlayerCount(roomCode.toUpperCase())
|
||||
.then(count => {
|
||||
console.log(count);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
184
scripts/jackbox-count-v3.js
Executable file
184
scripts/jackbox-count-v3.js
Executable file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
async function getPlayerCount(roomCode) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox'
|
||||
]
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Set a realistic user agent to avoid bot detection
|
||||
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
let playerCount = null;
|
||||
let roomValidated = false;
|
||||
|
||||
// Monitor network requests to see if API call happens
|
||||
page.on('response', async (response) => {
|
||||
const url = response.url();
|
||||
if (url.includes('ecast.jackboxgames.com/api/v2/rooms')) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[NETWORK] Room API called:', url, 'Status:', response.status());
|
||||
}
|
||||
if (response.status() === 200) {
|
||||
roomValidated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for console messages that contain the client/welcome WebSocket message
|
||||
page.on('console', async msg => {
|
||||
try {
|
||||
const args = msg.args();
|
||||
for (const arg of args) {
|
||||
const val = await arg.jsonValue();
|
||||
const str = typeof val === 'object' ? JSON.stringify(val) : String(val);
|
||||
|
||||
// Debug: log all console messages that might be relevant
|
||||
if (process.env.DEBUG && (str.includes('welcome') || str.includes('here') || str.includes('opcode'))) {
|
||||
console.error('[CONSOLE]', str.substring(0, 200));
|
||||
}
|
||||
|
||||
// Look for the client/welcome message with player data
|
||||
if (str.includes('client/welcome')) {
|
||||
try {
|
||||
let data;
|
||||
if (typeof val === 'object') {
|
||||
data = val;
|
||||
} else {
|
||||
// The string might be "recv <- {...}" so extract the JSON part
|
||||
const jsonStart = str.indexOf('{');
|
||||
if (jsonStart !== -1) {
|
||||
const jsonStr = str.substring(jsonStart);
|
||||
data = JSON.parse(jsonStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data.opcode === 'client/welcome' && data.result) {
|
||||
// Look for the "here" object which contains all connected players
|
||||
if (data.result.here) {
|
||||
playerCount = Object.keys(data.result.here).length;
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[SUCCESS] Found player count:', playerCount);
|
||||
}
|
||||
} else if (process.env.DEBUG) {
|
||||
console.error('[DEBUG] client/welcome found but no "here" object. Keys:', Object.keys(data.result));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[PARSE ERROR]', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
if (process.env.DEBUG) console.error('[1] Navigating to jackbox.tv...');
|
||||
await page.goto('https://jackbox.tv/', { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
|
||||
// Wait for the room code input to be ready
|
||||
if (process.env.DEBUG) console.error('[2] Waiting for form...');
|
||||
await page.waitForSelector('input#roomcode', { timeout: 10000 });
|
||||
|
||||
// Type the room code using the input ID (more reliable)
|
||||
// Use the element.type() method which properly triggers React events
|
||||
if (process.env.DEBUG) console.error('[3] Typing room code:', roomCode);
|
||||
const roomInput = await page.$('input#roomcode');
|
||||
await roomInput.type(roomCode.toUpperCase(), { delay: 50 }); // Reduced delay from 100ms to 50ms
|
||||
|
||||
// Wait for room validation (the app info appears after validation)
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[4] Waiting for room validation...');
|
||||
const roomValue = await page.evaluate(() => document.querySelector('input#roomcode').value);
|
||||
console.error('[4] Room code value:', roomValue);
|
||||
}
|
||||
|
||||
// Actually wait for the validation to complete - the game name label appears
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const labels = Array.from(document.querySelectorAll('div, span, label'));
|
||||
return labels.some(el => {
|
||||
const text = el.textContent;
|
||||
return text.includes('Trivia') || text.includes('Party') || text.includes('Quiplash') ||
|
||||
text.includes('Fibbage') || text.includes('Drawful') || text.includes('Murder');
|
||||
});
|
||||
}, { timeout: 5000 });
|
||||
if (process.env.DEBUG) console.error('[4.5] Room validated successfully!');
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) console.error('[4.5] Room validation timeout - continuing anyway...');
|
||||
}
|
||||
|
||||
// Type the name using the input ID
|
||||
// This will trigger the input event that enables the Play button
|
||||
if (process.env.DEBUG) console.error('[5] Typing name...');
|
||||
const nameInput = await page.$('input#username');
|
||||
await nameInput.type('Observer', { delay: 30 }); // Reduced delay from 100ms to 30ms
|
||||
|
||||
// Wait a moment for the button to enable and click immediately
|
||||
if (process.env.DEBUG) console.error('[6] Waiting for Play button...');
|
||||
await page.waitForFunction(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const playBtn = buttons.find(b => {
|
||||
const text = b.textContent.toUpperCase();
|
||||
return (text.includes('PLAY') || text.includes('RECONNECT')) && !b.disabled;
|
||||
});
|
||||
return playBtn !== undefined;
|
||||
}, { timeout: 5000 }); // Reduced timeout from 10s to 5s
|
||||
|
||||
if (process.env.DEBUG) console.error('[7] Clicking Play...');
|
||||
await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const playBtn = buttons.find(b => {
|
||||
const text = b.textContent.toUpperCase();
|
||||
return (text.includes('PLAY') || text.includes('RECONNECT')) && !b.disabled;
|
||||
});
|
||||
if (playBtn) {
|
||||
playBtn.click();
|
||||
} else {
|
||||
throw new Error('Play button not found or still disabled');
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the WebSocket player count message (up to 5 seconds)
|
||||
if (process.env.DEBUG) console.error('[8] Waiting for player count message...');
|
||||
for (let i = 0; i < 10 && playerCount === null; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
if (playerCount === null) {
|
||||
throw new Error('Could not get player count from WebSocket');
|
||||
}
|
||||
|
||||
return playerCount;
|
||||
}
|
||||
|
||||
// Main
|
||||
const roomCode = process.argv[2];
|
||||
if (!roomCode) {
|
||||
console.error('Usage: node jackbox-count-v3.js <ROOM_CODE>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
getPlayerCount(roomCode.toUpperCase())
|
||||
.then(count => {
|
||||
console.log(count);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
190
scripts/jackbox-count.js
Executable file
190
scripts/jackbox-count.js
Executable file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
async function checkRoomStatus(roomCode) {
|
||||
try {
|
||||
const response = await fetch(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const roomData = data.body || data;
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[API] Room data:', JSON.stringify(roomData, null, 2));
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
locked: roomData.locked || false,
|
||||
full: roomData.full || false,
|
||||
maxPlayers: roomData.maxPlayers || 8,
|
||||
minPlayers: roomData.minPlayers || 0
|
||||
};
|
||||
}
|
||||
return { exists: false };
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[API] Error checking room:', e.message);
|
||||
}
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function getPlayerCountFromAudience(roomCode) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
|
||||
let playerCount = null;
|
||||
|
||||
// Enable CDP and listen for WebSocket frames BEFORE navigating
|
||||
const client = await page.target().createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
|
||||
client.on('Network.webSocketFrameReceived', ({ response }) => {
|
||||
if (response.payloadData && playerCount === null) {
|
||||
try {
|
||||
const data = JSON.parse(response.payloadData);
|
||||
|
||||
// Check for bc:room with player count data
|
||||
let roomVal = null;
|
||||
|
||||
if (data.opcode === 'client/welcome' && data.result?.entities?.['bc:room']) {
|
||||
roomVal = data.result.entities['bc:room'][1]?.val;
|
||||
}
|
||||
|
||||
if (data.opcode === 'object' && data.result?.key === 'bc:room') {
|
||||
roomVal = data.result.val;
|
||||
}
|
||||
|
||||
if (roomVal) {
|
||||
// Strategy 1: Game ended - use gameResults
|
||||
if (roomVal.gameResults?.players) {
|
||||
playerCount = roomVal.gameResults.players.length;
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[SUCCESS] Found', playerCount, 'players from gameResults');
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Game in progress - use analytics
|
||||
if (playerCount === null && roomVal.analytics) {
|
||||
const startAnalytic = roomVal.analytics.find(a => a.action === 'start');
|
||||
if (startAnalytic?.value) {
|
||||
playerCount = startAnalytic.value;
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[SUCCESS] Found', playerCount, 'players from analytics');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
if (process.env.DEBUG) console.error('[2] Navigating to jackbox.tv...');
|
||||
await page.goto('https://jackbox.tv/', { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
|
||||
if (process.env.DEBUG) console.error('[3] Waiting for form...');
|
||||
await page.waitForSelector('input#roomcode', { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
if (process.env.DEBUG) console.error('[4] Typing room code:', roomCode);
|
||||
const roomInput = await page.$('input#roomcode');
|
||||
await roomInput.type(roomCode.toUpperCase(), { delay: 50 });
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
if (process.env.DEBUG) console.error('[5] Typing name...');
|
||||
const nameInput = await page.$('input#username');
|
||||
await nameInput.type('CountBot', { delay: 30 });
|
||||
|
||||
if (process.env.DEBUG) console.error('[6] Waiting for JOIN AUDIENCE button...');
|
||||
await page.waitForFunction(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
return buttons.some(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
|
||||
}, { timeout: 10000 });
|
||||
|
||||
if (process.env.DEBUG) console.error('[7] Clicking JOIN AUDIENCE...');
|
||||
await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const btn = buttons.find(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
|
||||
if (btn) btn.click();
|
||||
});
|
||||
|
||||
// Wait for WebSocket messages
|
||||
if (process.env.DEBUG) console.error('[8] Waiting for player count...');
|
||||
for (let i = 0; i < 20 && playerCount === null; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
return playerCount;
|
||||
}
|
||||
|
||||
async function getPlayerCount(roomCode) {
|
||||
if (process.env.DEBUG) console.error('[1] Checking room status via API...');
|
||||
const roomStatus = await checkRoomStatus(roomCode);
|
||||
|
||||
if (!roomStatus.exists) {
|
||||
if (process.env.DEBUG) console.error('[ERROR] Room does not exist');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If full, return maxPlayers
|
||||
if (roomStatus.full) {
|
||||
if (process.env.DEBUG) console.error('[1] Room is FULL - returning maxPlayers:', roomStatus.maxPlayers);
|
||||
return roomStatus.maxPlayers;
|
||||
}
|
||||
|
||||
// If locked (game in progress), join as audience to get count
|
||||
if (roomStatus.locked) {
|
||||
if (process.env.DEBUG) console.error('[1] Room is LOCKED - joining as audience...');
|
||||
|
||||
try {
|
||||
const count = await getPlayerCountFromAudience(roomCode);
|
||||
if (count !== null) {
|
||||
return count;
|
||||
}
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) console.error('[ERROR] Failed to get count:', e.message);
|
||||
}
|
||||
|
||||
// Fallback to maxPlayers if we couldn't get exact count
|
||||
if (process.env.DEBUG) console.error('[1] Could not get exact count, returning maxPlayers:', roomStatus.maxPlayers);
|
||||
return roomStatus.maxPlayers;
|
||||
}
|
||||
|
||||
// Not locked (lobby open) - don't join, return minPlayers
|
||||
if (process.env.DEBUG) console.error('[1] Room is NOT locked (lobby) - returning minPlayers:', roomStatus.minPlayers);
|
||||
return roomStatus.minPlayers;
|
||||
}
|
||||
|
||||
// Main
|
||||
const roomCode = process.argv[2];
|
||||
if (!roomCode) {
|
||||
console.error('Usage: node jackbox-count.js <ROOM_CODE>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
getPlayerCount(roomCode.toUpperCase())
|
||||
.then(count => {
|
||||
console.log(count);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
if (process.env.DEBUG) console.error('Error:', err.message);
|
||||
console.log(0);
|
||||
process.exit(0);
|
||||
});
|
||||
112
scripts/jackbox-player-count.go
Normal file
112
scripts/jackbox-player-count.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: go run jackbox-player-count.go <ROOM_CODE>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
roomCode := strings.ToUpper(os.Args[1])
|
||||
count, err := getPlayerCount(roomCode)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("%d\n", count)
|
||||
}
|
||||
|
||||
func getPlayerCount(roomCode string) (int, error) {
|
||||
// Suppress cookie errors
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
)
|
||||
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
|
||||
ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(func(s string, i ...interface{}) {
|
||||
msg := fmt.Sprintf(s, i...)
|
||||
if !strings.Contains(msg, "cookie") {
|
||||
log.Printf(msg)
|
||||
}
|
||||
}))
|
||||
defer cancel()
|
||||
|
||||
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var playerCount int
|
||||
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.Navigate("https://jackbox.tv/"),
|
||||
chromedp.WaitVisible(`input[placeholder*="ENTER 4-LETTER CODE"]`),
|
||||
|
||||
// Inject WebSocket hook BEFORE joining
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
return chromedp.Evaluate(`
|
||||
window.__jackboxPlayerCount = null;
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
window.WebSocket = function(...args) {
|
||||
const ws = new OriginalWebSocket(...args);
|
||||
const originalAddEventListener = ws.addEventListener;
|
||||
ws.addEventListener = function(type, listener, ...rest) {
|
||||
if (type === 'message') {
|
||||
const wrappedListener = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.opcode === 'client/welcome' && data.result && data.result.here) {
|
||||
window.__jackboxPlayerCount = Object.keys(data.result.here).length;
|
||||
}
|
||||
} catch (e) {}
|
||||
return listener.call(this, event);
|
||||
};
|
||||
return originalAddEventListener.call(this, type, wrappedListener, ...rest);
|
||||
}
|
||||
return originalAddEventListener.call(this, type, listener, ...rest);
|
||||
};
|
||||
return ws;
|
||||
};
|
||||
`, nil).Do(ctx)
|
||||
}),
|
||||
|
||||
// Now join
|
||||
chromedp.SendKeys(`input[placeholder*="ENTER 4-LETTER CODE"]`, roomCode+"\n\n"),
|
||||
|
||||
// Poll for the value
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
for i := 0; i < 60; i++ { // Try for 30 seconds
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
var count int
|
||||
err := chromedp.Evaluate(`window.__jackboxPlayerCount || -1`, &count).Do(ctx)
|
||||
if err == nil && count > 0 {
|
||||
playerCount = count
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for player count")
|
||||
}),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if playerCount < 0 {
|
||||
return 0, fmt.Errorf("could not find player count")
|
||||
}
|
||||
|
||||
return playerCount, nil
|
||||
}
|
||||
|
||||
1127
scripts/package-lock.json
generated
Normal file
1127
scripts/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
scripts/package.json
Normal file
16
scripts/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "jackbox-scripts",
|
||||
"version": "1.0.0",
|
||||
"description": "Utility scripts for Jackbox game integration",
|
||||
"main": "jackbox-count.js",
|
||||
"scripts": {
|
||||
"count": "node jackbox-count.js"
|
||||
},
|
||||
"keywords": ["jackbox", "websocket", "player-count"],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"puppeteer": "^24.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user