trigo / trigo-web /app /src /views /SocketTestView.vue
k-l-lambda's picture
Update trigo-web with VS People multiplayer mode
15f353f
<template>
<div class="socket-test-view">
<div class="test-header">
<h1>Socket API Test Page</h1>
<p class="subtitle">Testing Backend Game Socket Handlers</p>
</div>
<div class="test-body">
<!-- Left Column: Controls and Status -->
<div class="left-column">
<!-- Connection Status -->
<div class="test-section">
<h2>Connection Status</h2>
<div class="status-info">
<div class="status-item" :class="{ ready: connected, error: error }">
<span class="status-label">Socket:</span>
<span class="status-value">
{{ connected ? "✓ Connected" : "⚪ Disconnected" }}
</span>
</div>
<div v-if="socket" class="status-item">
<span class="status-label">Socket ID:</span>
<span class="status-value code">{{ socket.id }}</span>
</div>
<div v-if="error" class="error-message">❌ Error: {{ error }}</div>
</div>
</div>
<!-- Room Management -->
<div class="test-section">
<h2>Room Management</h2>
<div class="room-info">
<div class="info-item">
<span class="label">Current Room:</span>
<span class="value code">{{ currentRoomId || "Not in room" }}</span>
</div>
<div v-if="playerColor" class="info-item">
<span class="label">Player Color:</span>
<span class="value" :class="playerColor">{{ playerColor }}</span>
</div>
<div v-if="currentRoomId" class="info-item">
<span class="label">Room Admin:</span>
<span class="value">{{ isAdmin ? "You (Admin)" : "Other player" }}</span>
</div>
</div>
<div class="room-controls">
<div class="control-group">
<input
v-model="nickname"
type="text"
placeholder="Nickname"
class="input-text"
/>
<input
v-model="roomIdInput"
type="text"
placeholder="Room ID (leave empty to create)"
class="input-text"
/>
</div>
<div class="button-group">
<button
class="btn btn-primary"
@click="testJoinRoom"
:disabled="!connected || currentRoomId !== null"
>
Join/Create Room
</button>
<button
class="btn btn-secondary"
@click="testLeaveRoom"
:disabled="!currentRoomId"
>
Leave Room
</button>
</div>
</div>
</div>
<!-- Game State Display -->
<div class="test-section">
<h2>Game State</h2>
<div class="game-info">
<div class="info-item">
<span class="label">Game Status:</span>
<span class="value">{{ gameState.gameStatus || "N/A" }}</span>
</div>
<div class="info-item">
<span class="label">Current Player:</span>
<span class="value" :class="gameState.currentPlayer">
{{ gameState.currentPlayer || "N/A" }}
</span>
</div>
<div class="info-item">
<span class="label">Current Index:</span>
<span class="value">{{ gameState.currentMoveIndex }}</span>
</div>
<div class="info-item">
<span class="label">Captured (Black):</span>
<span class="value">{{ gameState.capturedStones.black }}</span>
</div>
<div class="info-item">
<span class="label">Captured (White):</span>
<span class="value">{{ gameState.capturedStones.white }}</span>
</div>
</div>
<!-- TGN Display -->
<div v-if="gameState.tgn" class="tgn-display">
<h3>Current TGN:</h3>
<pre class="tgn-content">{{ gameState.tgn }}</pre>
</div>
</div>
<!-- Game Actions -->
<div class="test-section">
<h2>Game Actions</h2>
<div class="game-controls">
<div class="control-group">
<input
v-model.number="moveX"
type="number"
placeholder="X"
class="input-number"
min="0"
max="4"
/>
<input
v-model.number="moveY"
type="number"
placeholder="Y"
class="input-number"
min="0"
max="4"
/>
<input
v-model.number="moveZ"
type="number"
placeholder="Z"
class="input-number"
min="0"
max="4"
/>
<button
class="btn btn-primary"
@click="testMakeMove"
:disabled="!currentRoomId"
>
Make Move
</button>
</div>
<div class="button-group">
<button
class="btn btn-secondary"
@click="testPass"
:disabled="!currentRoomId"
>
Pass
</button>
<button
class="btn btn-warning"
@click="testResign"
:disabled="!currentRoomId"
>
Resign
</button>
</div>
</div>
</div>
<!-- Admin Actions -->
<div class="test-section admin-section">
<h2>Admin Actions (Undo/Redo/Reset)</h2>
<p class="section-desc">Test the new admin APIs</p>
<div v-if="!isAdmin && currentRoomId" class="admin-warning">
⚠️ Only the room admin can reset the game
</div>
<div class="button-group">
<button
class="btn btn-admin"
@click="testUndo"
:disabled="!currentRoomId || gameState.currentMoveIndex === 0"
>
Undo Move
</button>
<button
class="btn btn-admin"
@click="testRedo"
:disabled="!currentRoomId"
>
Redo Move
</button>
</div>
<div class="reset-controls">
<div class="control-group">
<label class="input-label">Board Shape:</label>
<input
v-model.number="boardShapeX"
type="number"
placeholder="X"
class="input-number"
min="3"
max="9"
/>
<input
v-model.number="boardShapeY"
type="number"
placeholder="Y"
class="input-number"
min="3"
max="9"
/>
<input
v-model.number="boardShapeZ"
type="number"
placeholder="Z"
class="input-number"
min="3"
max="9"
/>
</div>
<div v-if="roomPlayers.length === 2" class="control-group">
<label class="checkbox-label">
<input type="checkbox" v-model="swapColors" />
Swap Player Colors (Black ↔ White)
</label>
</div>
<button class="btn btn-danger" @click="testReset" :disabled="!currentRoomId || !isAdmin">
Reset Game (Admin Only)
</button>
</div>
</div>
</div>
<!-- End Left Column -->
<!-- Right Column: Event Log -->
<div class="right-column">
<!-- Event Log -->
<div class="test-section event-log-section">
<h2>Event Log</h2>
<div class="event-log-controls">
<button class="btn btn-secondary" @click="clearEventLog">Clear Log</button>
<label class="checkbox-label">
<input type="checkbox" v-model="autoScroll" />
Auto-scroll
</label>
</div>
<div class="event-log" ref="eventLogRef">
<div v-if="eventLog.length === 0" class="empty-state">No events yet</div>
<div
v-for="(event, index) in eventLog"
:key="index"
class="event-item"
:class="event.type"
>
<span class="event-timestamp">{{ event.timestamp }}</span>
<span class="event-name">{{ event.name }}</span>
<span class="event-type-badge">{{ event.type }}</span>
<pre class="event-data">{{ JSON.stringify(event.data, null, 2) }}</pre>
</div>
</div>
</div>
</div>
<!-- End Right Column -->
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, nextTick, watch } from "vue";
import { useSocket } from "@/composables/useSocket";
// Socket composable
const { socket, connected, error } = useSocket();
// Room state
const currentRoomId = ref<string | null>(null);
const playerColor = ref<string | null>(null);
const nickname = ref("TestUser");
const roomIdInput = ref("");
const isAdmin = ref(false);
const roomPlayers = ref<string[]>([]);
const playerColorAssignments = reactive<{ [playerId: string]: "black" | "white" }>({});
const swapColors = ref(false);
// Game state
const gameState = reactive({
currentPlayer: null as string | null,
currentMoveIndex: 0,
capturedStones: { black: 0, white: 0 },
gameStatus: null as string | null,
tgn: "" as string
});
// Move inputs
const moveX = ref(0);
const moveY = ref(0);
const moveZ = ref(0);
// Board shape inputs for reset
const boardShapeX = ref(5);
const boardShapeY = ref(5);
const boardShapeZ = ref(5);
// Event log
const eventLog = ref<
Array<{
timestamp: string;
name: string;
type: "sent" | "received" | "error";
data: any;
}>
>([]);
const eventLogRef = ref<HTMLElement | null>(null);
const autoScroll = ref(true);
// Helper to log events
function logEvent(name: string, data: any, type: "sent" | "received" | "error" = "received") {
const timestamp = new Date().toLocaleTimeString();
eventLog.value.push({ timestamp, name, type, data });
if (autoScroll.value) {
nextTick(() => {
if (eventLogRef.value) {
eventLogRef.value.scrollTop = eventLogRef.value.scrollHeight;
}
});
}
}
function clearEventLog() {
eventLog.value = [];
}
// Room management
function testJoinRoom() {
const data = {
roomId: roomIdInput.value || undefined,
nickname: nickname.value
};
logEvent("joinRoom", data, "sent");
socket.emit("joinRoom", data, (response: any) => {
logEvent("joinRoom:response", response, response.success ? "received" : "error");
if (response.success) {
currentRoomId.value = response.roomId;
playerColor.value = response.playerColor;
isAdmin.value = response.isAdmin || false;
// Initialize room players from response
if (response.players) {
roomPlayers.value = Object.keys(response.players);
// Initialize player color assignments
for (const [playerId, playerInfo] of Object.entries(response.players) as any) {
playerColorAssignments[playerId] = playerInfo.color;
}
}
// Update game state
if (response.gameState) {
Object.assign(gameState, response.gameState);
// Update board shape inputs
if (response.gameState.boardShape) {
boardShapeX.value = response.gameState.boardShape.x;
boardShapeY.value = response.gameState.boardShape.y;
boardShapeZ.value = response.gameState.boardShape.z;
}
}
}
});
}
function testLeaveRoom() {
logEvent("leaveRoom", {}, "sent");
socket.emit("leaveRoom");
// Reset local state
currentRoomId.value = null;
playerColor.value = null;
Object.assign(gameState, {
currentPlayer: null,
currentMoveIndex: 0,
capturedStones: { black: 0, white: 0 },
gameStatus: null,
tgn: ""
});
}
// Game actions
function testMakeMove() {
const data = { x: moveX.value, y: moveY.value, z: moveZ.value };
logEvent("makeMove", data, "sent");
socket.emit("makeMove", data);
}
function testPass() {
logEvent("pass", {}, "sent");
socket.emit("pass");
}
function testResign() {
if (!confirm("Are you sure you want to resign?")) return;
logEvent("resign", {}, "sent");
socket.emit("resign");
}
// Admin actions
function testUndo() {
logEvent("undoMove", {}, "sent");
socket.emit("undoMove", (response: any) => {
logEvent("undoMove:response", response, response.success ? "received" : "error");
});
}
function testRedo() {
logEvent("redoMove", {}, "sent");
socket.emit("redoMove", (response: any) => {
logEvent("redoMove:response", response, response.success ? "received" : "error");
});
}
function testReset() {
if (!confirm("Reset entire game?")) return;
const data: any = {
boardShape: {
x: boardShapeX.value,
y: boardShapeY.value,
z: boardShapeZ.value
}
};
// Apply color swap if checkbox is checked
if (swapColors.value && roomPlayers.value.length === 2) {
const [player1, player2] = roomPlayers.value;
const color1 = playerColorAssignments[player1];
const color2 = playerColorAssignments[player2];
// Swap the colors
data.playerColors = {
[player1]: color2,
[player2]: color1
};
}
logEvent("resetGame", data, "sent");
socket.emit("resetGame", data, (response: any) => {
logEvent("resetGame:response", response, response.success ? "received" : "error");
});
}
// Socket event listeners
onMounted(() => {
// Listen to gameUpdate
socket.on("gameUpdate", (data: any) => {
logEvent("gameUpdate", data, "received");
// Update local game state
if (data.currentPlayer !== undefined) gameState.currentPlayer = data.currentPlayer;
if (data.currentMoveIndex !== undefined) gameState.currentMoveIndex = data.currentMoveIndex;
if (data.capturedStones !== undefined) gameState.capturedStones = data.capturedStones;
if (data.tgn !== undefined) gameState.tgn = data.tgn;
});
// Listen to gameReset
socket.on("gameReset", (data: any) => {
logEvent("gameReset", data, "received");
// Reset local game state
gameState.currentPlayer = data.currentPlayer;
gameState.currentMoveIndex = 0;
gameState.capturedStones = { black: 0, white: 0 };
gameState.tgn = data.tgn || "";
// Update board shape inputs
if (data.boardShape) {
boardShapeX.value = data.boardShape.x;
boardShapeY.value = data.boardShape.y;
boardShapeZ.value = data.boardShape.z;
}
// Update player list and colors if provided
if (data.players) {
roomPlayers.value = Object.keys(data.players);
// Initialize player color assignments
for (const [playerId, playerInfo] of Object.entries(data.players) as any) {
playerColorAssignments[playerId] = playerInfo.color;
}
// Update current player's color display
if (data.players[socket.id]) {
playerColor.value = data.players[socket.id].color;
}
}
});
// Listen to gameEnded
socket.on("gameEnded", (data: any) => {
logEvent("gameEnded", data, "received");
gameState.gameStatus = "finished";
});
// Listen to playerJoined
socket.on("playerJoined", (data: any) => {
logEvent("playerJoined", data, "received");
// Add the new player to the room players list
if (data.playerId && !roomPlayers.value.includes(data.playerId)) {
roomPlayers.value.push(data.playerId);
// Default the new player to white (second player)
playerColorAssignments[data.playerId] = "white";
}
});
// Listen to playerLeft
socket.on("playerLeft", (data: any) => {
logEvent("playerLeft", data, "received");
});
// Listen to nicknameChanged
socket.on("nicknameChanged", (data: any) => {
logEvent("nicknameChanged", data, "received");
});
// Listen to error events
socket.on("error", (data: any) => {
logEvent("error", data, "error");
});
});
onUnmounted(() => {
// Clean up listeners
socket.off("gameUpdate");
socket.off("gameReset");
socket.off("gameEnded");
socket.off("playerJoined");
socket.off("playerLeft");
socket.off("nicknameChanged");
socket.off("error");
});
</script>
<style scoped>
.socket-test-view {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.test-header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.test-header h1 {
margin: 0 0 10px 0;
font-size: 2em;
color: #333;
}
.subtitle {
margin: 0;
color: #666;
font-size: 1.1em;
}
.test-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
overflow: auto;
max-height: calc(100vh - 200px);
}
.left-column {
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
}
.right-column {
display: flex;
flex-direction: column;
min-height: 0;
}
.event-log-section {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.test-section {
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
}
.test-section h2 {
margin: 0 0 15px 0;
font-size: 1.5em;
color: #333;
}
.section-desc {
margin: -10px 0 15px 0;
color: #666;
font-size: 0.9em;
}
.admin-section {
background: #fff8e1;
border-color: #ffd54f;
}
/* Status */
.status-info,
.room-info,
.game-info {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 15px;
}
.status-item,
.info-item {
display: flex;
gap: 10px;
align-items: center;
padding: 8px;
background: white;
border-radius: 4px;
}
.status-item.ready {
background: #e8f5e9;
}
.status-item.error {
background: #ffebee;
}
.status-label,
.label {
font-weight: 600;
min-width: 120px;
}
.status-value,
.value {
flex: 1;
}
.value.black {
color: #000;
font-weight: bold;
}
.value.white {
color: #999;
font-weight: bold;
}
.code {
font-family: "Courier New", monospace;
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
.error-message {
padding: 10px;
background: #ffebee;
border: 1px solid #ef5350;
border-radius: 4px;
color: #c62828;
}
/* Controls */
.room-controls,
.game-controls {
display: flex;
flex-direction: column;
gap: 10px;
}
.control-group,
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.reset-controls {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.input-label {
font-weight: 600;
margin-right: 5px;
}
.input-text,
.input-number,
.input-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1em;
flex: 1;
min-width: 150px;
}
.input-number {
min-width: 80px;
max-width: 100px;
}
.input-select {
min-width: 200px;
cursor: pointer;
background: white;
}
.admin-warning {
padding: 10px;
background: #fff8e1;
border: 1px solid #ffd54f;
border-radius: 4px;
color: #f57c00;
font-weight: 500;
margin-bottom: 10px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 1em;
cursor: pointer;
transition: background 0.2s;
font-weight: 500;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #2196f3;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #1976d2;
}
.btn-secondary {
background: #757575;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #616161;
}
.btn-warning {
background: #ff9800;
color: white;
}
.btn-warning:hover:not(:disabled) {
background: #f57c00;
}
.btn-danger {
background: #f44336;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #d32f2f;
}
.btn-admin {
background: #9c27b0;
color: white;
}
.btn-admin:hover:not(:disabled) {
background: #7b1fa2;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
/* Event Log */
.event-log-controls {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.event-log {
flex: 1;
overflow-y: auto;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
min-height: 0;
}
.empty-state {
text-align: center;
color: #999;
padding: 20px;
}
/* TGN Display */
.tgn-display {
margin-top: 15px;
}
.tgn-display h3 {
margin: 0 0 10px 0;
font-size: 1.1em;
color: #333;
}
.tgn-content {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
margin: 0;
font-family: "Courier New", monospace;
font-size: 0.9em;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 200px;
overflow-y: auto;
}
.event-item {
margin-bottom: 10px;
padding: 10px;
border-radius: 4px;
border-left: 4px solid #2196f3;
background: #f5f5f5;
}
.event-item.sent {
border-left-color: #4caf50;
background: #e8f5e9;
}
.event-item.error {
border-left-color: #f44336;
background: #ffebee;
}
.event-timestamp {
font-size: 0.85em;
color: #666;
margin-right: 10px;
}
.event-name {
font-weight: 600;
margin-right: 10px;
}
.event-type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
}
.event-item.sent .event-type-badge {
background: #4caf50;
color: white;
}
.event-item.received .event-type-badge {
background: #2196f3;
color: white;
}
.event-item.error .event-type-badge {
background: #f44336;
color: white;
}
.event-data {
margin: 10px 0 0 0;
padding: 10px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.85em;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
</style>