initial actual player count implementation

This commit is contained in:
cottongin
2025-11-03 13:57:26 -05:00
parent 2a75237e90
commit 140988d01d
14 changed files with 3428 additions and 0 deletions

View File

@@ -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
View 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
View 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

View 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
View 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()
}

View 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
View 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
View 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
View 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
View 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
View 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);
});

View 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

File diff suppressed because it is too large Load Diff

16
scripts/package.json Normal file
View 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"
}
}