task/134-frontend-check #100

Merged
Donat merged 6 commits from task/134-frontend-check into main 2025-11-17 19:40:56 +01:00
25 changed files with 1508 additions and 445 deletions
Showing only changes of commit 5479ca7f16 - Show all commits
+32
View File
@@ -970,6 +970,8 @@ socket.on('game:ended', {
message: string;
finalPositions: PlayerPosition[];
timestamp: string;
reason?: string; // Optional: 'gamemaster_left' if GM disconnected
gamemasterName?: string; // Optional: GM name if GM left
});
// Server → All Players: Cleanup complete
@@ -980,6 +982,36 @@ socket.on('game:cleanup-complete', {
});
```
**Game End Scenarios**:
1. **Normal Win** (Player reaches position 100):
```typescript
{
winner: "player-uuid",
winnerName: "Alice",
message: "🎉 Alice won the game! Congratulations!",
finalPositions: [...],
timestamp: "2025-11-06T..."
}
```
2. **Gamemaster Disconnect** (Game cancelled):
```typescript
{
reason: "gamemaster_left",
gamemasterName: "Bob",
message: "🎭 Gamemaster Bob left. Game has ended.",
timestamp: "2025-11-06T..."
}
```
**Behavior**:
- Game immediately cancelled when gamemaster disconnects
- All players notified via `game:ended` event
- Database updated: `state = CANCELLED`, `enddate = now`
- All Redis data and socket connections cleaned up
- No winner recorded (game didn't complete normally)
---
#### Error Events
Binary file not shown.
@@ -68,8 +68,6 @@ export class StartGameCommandHandler {
orgid: command.orgid || null,
gamedecks,
players: [],
started: false,
finished: false,
winner: null,
state: GameState.WAITING,
startdate: null,
@@ -65,7 +65,6 @@ export class StartGamePlayCommandHandler {
// Update game state in database
const updatedGame = await this.gameRepository.update(game.id, {
started: true,
state: GameState.ACTIVE,
startdate: new Date()
});
@@ -111,11 +110,6 @@ export class StartGamePlayCommandHandler {
throw new Error('Game is not in waiting state and cannot be started');
}
// Check if game is already started
if (game.started) {
throw new Error('Game has already been started');
}
// Check if there are enough players (at least 2)
if (game.players.length < 2) {
throw new Error('Game needs at least 2 players to start');
@@ -1139,6 +1139,36 @@ export class GameWebSocketService {
// If the socket was in a game, handle cleanup
if (socket.gameCode && socket.playerName) {
try {
// Check if this player is the gamemaster
const game = await this.gameRepository.findByGameCode(socket.gameCode);
const isGamemaster = game && socket.userId && game.createdby === socket.userId;
// If gamemaster leaves, end the game immediately
if (isGamemaster && game) {
logOther(`Gamemaster ${socket.playerName} left game ${socket.gameCode}, ending game`);
const gameRoomName = `game_${socket.gameCode}`;
// Notify all players
this.io.of('/game').to(gameRoomName).emit('game:ended', {
reason: 'gamemaster_left',
gamemasterName: socket.playerName,
message: `🎭 Gamemaster ${socket.playerName} left. Game has ended.`,
timestamp: new Date().toISOString()
});
// Update database
await this.gameRepository.update(game.id, {
state: GameState.CANCELLED,
enddate: new Date()
});
// Clean up all game data
await this.cleanupGameData(socket.gameCode, game.id);
return; // Exit early, no need for further cleanup
}
// Clean up any pending card answer
if (socket.userId) {
const pendingCard = await this.getPendingCard(socket.gameCode, socket.userId);
@@ -1222,14 +1252,13 @@ export class GameWebSocketService {
if (!game) return;
// Only clean up games that haven't finished yet
if (!game.finished) {
if (game.state !== GameState.FINISHED && game.state !== GameState.CANCELLED) {
logOther(`Handling abandoned game ${gameCode}`, { gameId: game.id });
// Mark game as abandoned in database
// Mark game as cancelled in database
await this.gameRepository.update(game.id, {
finished: true,
state: GameState.CANCELLED,
enddate: new Date(),
// Could add an 'abandoned' flag if the database schema supports it
});
// Clean up all Redis data for this abandoned game
@@ -2236,8 +2265,8 @@ export class GameWebSocketService {
const game = await this.gameRepository.findByGameCode(gameCode);
if (game) {
await this.gameRepository.update(game.id, {
finished: true,
winner: winnerId,
state: GameState.FINISHED,
winnerId: winnerId,
enddate: new Date()
});
}
@@ -1,5 +1,7 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Consequence, CardType } from '../Deck/DeckAggregate';
import { UserAggregate } from '../User/UserAggregate';
import { OrganizationAggregate } from '../Organization/OrganizationAggregate';
export enum GameState {
WAITING = 0,
@@ -65,14 +67,8 @@ export class GameAggregate {
@Column({ type: 'uuid', array: true, default: () => "'{}'", name: 'playerids' })
players!: string[];
@Column({ type: 'boolean', default: false })
started!: boolean;
@Column({ type: 'boolean', default: false })
finished!: boolean;
@Column({ type: 'uuid', nullable: true, name: 'winnerid' })
winner!: string | null;
@Column({ type: 'uuid', nullable: true, name: 'winnerId' })
winnerId!: string | null;
@Column({ type: 'int', default: GameState.WAITING })
state!: GameState;
@@ -86,8 +82,20 @@ export class GameAggregate {
@Column({ type: 'timestamp', nullable: true, name: 'finishDate' })
enddate!: Date | null;
@UpdateDateColumn()
@UpdateDateColumn({ name: 'updateDate' })
updateDate!: Date;
@ManyToOne(() => UserAggregate, { eager: false })
@JoinColumn({ name: 'createdBy' })
user!: UserAggregate | null;
@ManyToOne(() => UserAggregate, { eager: false })
@JoinColumn({ name: 'winnerId' })
winner!: UserAggregate | null;
@ManyToOne(() => OrganizationAggregate, { eager: false })
@JoinColumn({ name: 'organizationId' })
organization!: OrganizationAggregate | null;
}
// Board Generation Types
@@ -1,32 +1,14 @@
import { GameAggregate } from '../Game/GameAggregate';
<<<<<<< HEAD
import { GameAggregate, GameState } from '../Game/GameAggregate';
import { IPaginatedRepository } from './IBaseRepository';
export interface IGameRepository extends IPaginatedRepository<GameAggregate, { games: GameAggregate[], totalCount: number }> {
// Game-specific methods
findByGameCode(gamecode: string): Promise<GameAggregate | null>;
=======
export interface IGameRepository {
create(game: Partial<GameAggregate>): Promise<GameAggregate>;
findByPage(from: number, to: number): Promise<{ games: GameAggregate[], totalCount: number }>;
findByPageIncludingDeleted(from: number, to: number): Promise<{ games: GameAggregate[], totalCount: number }>;
findById(id: string): Promise<GameAggregate | null>;
findByIdIncludingDeleted(id: string): Promise<GameAggregate | null>;
findByGameCode(gamecode: string): Promise<GameAggregate | null>;
search(query: string, limit?: number, offset?: number): Promise<{ games: GameAggregate[], totalCount: number }>;
searchIncludingDeleted(query: string, limit?: number, offset?: number): Promise<{ games: GameAggregate[], totalCount: number }>;
update(id: string, update: Partial<GameAggregate>): Promise<GameAggregate | null>;
delete(id: string): Promise<any>;
softDelete(id: string): Promise<GameAggregate | null>;
// Game-specific methods
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
findActiveGames(): Promise<GameAggregate[]>;
findGamesByPlayer(playerId: string): Promise<GameAggregate[]>;
findWaitingGames(): Promise<GameAggregate[]>;
findFinishedGames(from?: number, to?: number): Promise<{ games: GameAggregate[], totalCount: number }>;
addPlayerToGame(gameId: string, playerId: string): Promise<GameAggregate | null>;
removePlayerFromGame(gameId: string, playerId: string): Promise<GameAggregate | null>;
updateGameState(gameId: string, started: boolean, finished?: boolean, winner?: string): Promise<GameAggregate | null>;
updateGameState(gameId: string, state: GameState, winner?: string): Promise<GameAggregate | null>;
}
@@ -1,28 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Full1757939815984 implements MigrationInterface {
name = 'Full1757939815984'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "Chats" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying(50) NOT NULL DEFAULT 'direct', "name" character varying(255), "gameId" uuid, "createdBy" uuid, "users" uuid array NOT NULL, "messages" json NOT NULL DEFAULT '[]', "lastActivity" TIMESTAMP, "createDate" TIMESTAMP NOT NULL DEFAULT now(), "updateDate" TIMESTAMP NOT NULL DEFAULT now(), "state" integer NOT NULL DEFAULT '0', "archiveDate" TIMESTAMP, CONSTRAINT "PK_64c36c2b8d86a0d5de4cf64de8d" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "orgid" uuid, "username" character varying(100) NOT NULL, "password" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "fname" character varying(100) NOT NULL, "lname" character varying(100) NOT NULL, "token" character varying(255), "TokenExpires" TIMESTAMP, "phone" character varying(20), "state" integer NOT NULL DEFAULT '0', "regdate" TIMESTAMP NOT NULL DEFAULT now(), "updatedate" TIMESTAMP NOT NULL DEFAULT now(), "Orglogindate" TIMESTAMP, CONSTRAINT "UQ_ffc81a3b97dcbf8e320d5106c0d" UNIQUE ("username"), CONSTRAINT "UQ_3c3ab3f49a87e6ddb607f3c4945" UNIQUE ("email"), CONSTRAINT "PK_16d4f7d636df336db11d87413e3" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Contacts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "userid" uuid, "type" integer NOT NULL, "txt" text NOT NULL, "state" integer NOT NULL DEFAULT '0', "createDate" TIMESTAMP NOT NULL DEFAULT now(), "updateDate" TIMESTAMP NOT NULL DEFAULT now(), "adminResponse" text, "responseDate" TIMESTAMP, "respondedBy" uuid, CONSTRAINT "PK_68782cec65c8eef577c62958273" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "ChatArchives" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "chatId" uuid NOT NULL, "archivedMessages" json NOT NULL, "archivedAt" TIMESTAMP NOT NULL, "createDate" TIMESTAMP NOT NULL DEFAULT now(), "chatType" character varying(50) NOT NULL, "chatName" character varying(255), "gameId" uuid, "participants" uuid array NOT NULL, CONSTRAINT "PK_fe62979fc2061d7afe278d3f14e" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Games" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "gamecode" character varying(10) NOT NULL, "maxplayers" integer NOT NULL, "logintype" integer NOT NULL DEFAULT '0', "createdby" character varying(255), "orgid" character varying(255), "gamedecks" json NOT NULL, "players" json NOT NULL DEFAULT '[]', "started" boolean NOT NULL DEFAULT false, "finished" boolean NOT NULL DEFAULT false, "winner" character varying(255), "state" integer NOT NULL DEFAULT '0', "create_date" TIMESTAMP NOT NULL DEFAULT now(), "start_date" TIMESTAMP, "end_date" TIMESTAMP, "update_date" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_9d52c646079cbe6f242a85c5c41" UNIQUE ("gamecode"), CONSTRAINT "PK_1950492f583d31609c5e9fbbe12" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Organizations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "contactfname" character varying(100) NOT NULL, "contactlname" character varying(100) NOT NULL, "contactphone" character varying(20) NOT NULL, "contactemail" character varying(255) NOT NULL, "state" integer NOT NULL DEFAULT '0', "regdate" TIMESTAMP NOT NULL DEFAULT now(), "updatedate" TIMESTAMP NOT NULL DEFAULT now(), "url" character varying(500), "userinorg" integer NOT NULL DEFAULT '0', "maxOrganizationalDecks" integer, CONSTRAINT "PK_e0690a31419f6666194423526f2" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Decks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "type" integer NOT NULL, "user_id" uuid NOT NULL, "creation_date" TIMESTAMP NOT NULL DEFAULT now(), "cards" json NOT NULL, "played_number" integer NOT NULL DEFAULT '0', "ctype" integer NOT NULL DEFAULT '0', "update_date" TIMESTAMP NOT NULL DEFAULT now(), "state" integer NOT NULL DEFAULT '0', "organization_id" uuid, CONSTRAINT "PK_001f26cb3ec39c1f25269943473" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "Decks" ADD CONSTRAINT "FK_06ee28f90d68543a03b14aebe13" FOREIGN KEY ("organization_id") REFERENCES "Organizations"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Decks" DROP CONSTRAINT "FK_06ee28f90d68543a03b14aebe13"`);
await queryRunner.query(`DROP TABLE "Decks"`);
await queryRunner.query(`DROP TABLE "Organizations"`);
await queryRunner.query(`DROP TABLE "Games"`);
await queryRunner.query(`DROP TABLE "ChatArchives"`);
await queryRunner.query(`DROP TABLE "Contacts"`);
await queryRunner.query(`DROP TABLE "Users"`);
await queryRunner.query(`DROP TABLE "Chats"`);
}
}
@@ -1,30 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Full1758463929834 implements MigrationInterface {
name = 'Full1758463929834'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "winner"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "create_date"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "end_date"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "update_date"`);
await queryRunner.query(`ALTER TABLE "Games" ADD "boardsize" integer NOT NULL DEFAULT '50'`);
await queryRunner.query(`ALTER TABLE "Games" ADD "winnerid" uuid`);
await queryRunner.query(`ALTER TABLE "Games" ADD "createDate" TIMESTAMP NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "Games" ADD "finishDate" TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "Games" ADD "updateDate" TIMESTAMP NOT NULL DEFAULT now()`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "updateDate"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "finishDate"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "createDate"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "winnerid"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "boardsize"`);
await queryRunner.query(`ALTER TABLE "Games" ADD "update_date" TIMESTAMP NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "Games" ADD "end_date" TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "Games" ADD "create_date" TIMESTAMP NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "Games" ADD "winner" character varying(255)`);
}
}
@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Full1762370334693 implements MigrationInterface {
name = 'Full1762370334693'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Games" RENAME COLUMN "winnerid" TO "winnerId"`);
await queryRunner.query(`ALTER TABLE "Games" ADD CONSTRAINT "FK_330362bff8b25bb573f31fb4023" FOREIGN KEY ("winnerId") REFERENCES "Users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Games" DROP CONSTRAINT "FK_330362bff8b25bb573f31fb4023"`);
await queryRunner.query(`ALTER TABLE "Games" RENAME COLUMN "winnerId" TO "winnerid"`);
}
}
@@ -1,10 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Full1758463928499 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}
@@ -1,7 +1,6 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Full1758463928499 implements MigrationInterface {
export class Full1762370333970 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
}
@@ -385,29 +385,26 @@ export class GameRepository implements IGameRepository {
}
}
async updateGameState(gameId: string, started: boolean, finished?: boolean, winner?: string): Promise<GameAggregate | null> {
async updateGameState(gameId: string, state: GameState, winner?: string): Promise<GameAggregate | null> {
const startTime = performance.now();
try {
const updateData: Partial<GameAggregate> = { started };
const updateData: Partial<GameAggregate> = { state };
if (started && !finished) {
updateData.state = GameState.ACTIVE;
if (state === GameState.ACTIVE) {
updateData.startdate = new Date();
}
if (finished) {
updateData.finished = true;
updateData.state = GameState.FINISHED;
if (state === GameState.FINISHED) {
updateData.enddate = new Date();
if (winner) {
updateData.winner = winner;
updateData.winnerId = winner;
}
}
const result = await this.update(gameId, updateData);
const endTime = performance.now();
logDatabase('Game state updated', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${gameId}, started: ${started}, finished: ${finished}, winner: ${winner}`);
logDatabase('Game state updated', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${gameId}, state: ${updateData.state}, winner: ${winner}`);
return result;
} catch (error) {
const endTime = performance.now();
+147 -177
View File
@@ -1,202 +1,172 @@
-- SerpentRace Database Schema
-- Generated from TypeORM Entity Aggregates
-- This file creates the complete database schema without initial data
-- This script was generated by the ERD tool in pgAdmin 4.
-- Please log an issue at https://github.com/pgadmin-org/pgadmin4/issues/new/choose if you find any bugs, including reproduction steps.
BEGIN;
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create Users table
CREATE TABLE "Users" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"orgid" UUID NULL,
"username" VARCHAR(100) UNIQUE NOT NULL,
"password" VARCHAR(255) NOT NULL,
"email" VARCHAR(255) UNIQUE NOT NULL,
"fname" VARCHAR(100) NOT NULL,
"lname" VARCHAR(100) NOT NULL,
"token" VARCHAR(255) NULL,
"TokenExpires" TIMESTAMP NULL,
"phone" VARCHAR(20) NULL,
"state" INTEGER NOT NULL DEFAULT 0,
"regdate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updateDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"Orglogindate" TIMESTAMP NULL
CREATE TABLE IF NOT EXISTS public."ChatArchives"
(
id uuid NOT NULL DEFAULT uuid_generate_v4(),
"chatId" uuid NOT NULL,
"archivedMessages" json NOT NULL,
"archivedAt" timestamp without time zone NOT NULL,
"createDate" timestamp without time zone NOT NULL DEFAULT now(),
"chatType" character varying(50) COLLATE pg_catalog."default" NOT NULL,
"chatName" character varying(255) COLLATE pg_catalog."default",
"gameId" uuid,
participants uuid[] NOT NULL,
CONSTRAINT "PK_fe62979fc2061d7afe278d3f14e" PRIMARY KEY (id)
);
-- Create Organizations table
CREATE TABLE "Organizations" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"name" VARCHAR(255) NOT NULL,
"contactfname" VARCHAR(100) NOT NULL,
"contactlname" VARCHAR(100) NOT NULL,
"contactphone" VARCHAR(20) NOT NULL,
"contactemail" VARCHAR(255) NOT NULL,
"state" INTEGER NOT NULL DEFAULT 0,
"regdate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updateDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"url" VARCHAR(500) NULL,
"userinorg" INTEGER NOT NULL DEFAULT 0,
"maxOrganizationalDecks" INTEGER NULL
CREATE TABLE IF NOT EXISTS public."Chats"
(
id uuid NOT NULL DEFAULT uuid_generate_v4(),
type character varying(50) COLLATE pg_catalog."default" NOT NULL DEFAULT 'direct'::character varying,
name character varying(255) COLLATE pg_catalog."default",
"gameId" uuid,
"createdBy" uuid,
users uuid[] NOT NULL,
messages json NOT NULL DEFAULT '[]'::json,
"lastActivity" timestamp without time zone,
"createDate" timestamp without time zone NOT NULL DEFAULT now(),
"updateDate" timestamp without time zone NOT NULL DEFAULT now(),
state integer NOT NULL DEFAULT 0,
"archiveDate" timestamp without time zone,
CONSTRAINT "PK_64c36c2b8d86a0d5de4cf64de8d" PRIMARY KEY (id)
);
-- Create Decks table
CREATE TABLE "Decks" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"name" VARCHAR(255) NOT NULL,
"type" INTEGER NOT NULL,
"user_id" UUID NOT NULL,
"creation_date" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cards" JSONB NOT NULL DEFAULT '[]',
"played_number" INTEGER NOT NULL DEFAULT 0,
"ctype" INTEGER NOT NULL DEFAULT 0,
"updateDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"state" INTEGER NOT NULL DEFAULT 0,
"organization_id" UUID NULL
CREATE TABLE IF NOT EXISTS public."Contacts"
(
id uuid NOT NULL DEFAULT uuid_generate_v4(),
name character varying(255) COLLATE pg_catalog."default" NOT NULL,
email character varying(255) COLLATE pg_catalog."default" NOT NULL,
userid uuid,
type integer NOT NULL,
txt text COLLATE pg_catalog."default" NOT NULL,
state integer NOT NULL DEFAULT 0,
"createDate" timestamp without time zone NOT NULL DEFAULT now(),
"updateDate" timestamp without time zone NOT NULL DEFAULT now(),
"adminResponse" text COLLATE pg_catalog."default",
"responseDate" timestamp without time zone,
"respondedBy" uuid,
CONSTRAINT "PK_68782cec65c8eef577c62958273" PRIMARY KEY (id)
);
-- Create Chats table
CREATE TABLE "Chats" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"type" VARCHAR(50) NOT NULL DEFAULT 'direct',
"name" VARCHAR(255) NULL,
"gameId" UUID NULL,
"createdBy" UUID NULL,
"users" UUID[] NOT NULL,
"messages" JSONB NOT NULL DEFAULT '[]',
"lastActivity" TIMESTAMP NULL,
"createDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updateDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"state" INTEGER NOT NULL DEFAULT 0,
"archiveDate" TIMESTAMP NULL
CREATE TABLE IF NOT EXISTS public."Decks"
(
id uuid NOT NULL DEFAULT uuid_generate_v4(),
name character varying(255) COLLATE pg_catalog."default" NOT NULL,
type integer NOT NULL,
user_id uuid NOT NULL,
creation_date timestamp without time zone NOT NULL DEFAULT now(),
cards json NOT NULL,
played_number integer NOT NULL DEFAULT 0,
ctype integer NOT NULL DEFAULT 0,
"updateDate" timestamp without time zone NOT NULL DEFAULT now(),
state integer NOT NULL DEFAULT 0,
organization_id uuid,
CONSTRAINT "PK_001f26cb3ec39c1f25269943473" PRIMARY KEY (id)
);
-- Create Contacts table
CREATE TABLE "Contacts" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"name" VARCHAR(255) NOT NULL,
"email" VARCHAR(255) NOT NULL,
"userid" UUID NULL,
"type" INTEGER NOT NULL,
"txt" TEXT NOT NULL,
"state" INTEGER NOT NULL DEFAULT 0,
"createDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updateDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"adminResponse" TEXT NULL,
"responseDate" TIMESTAMP NULL,
"respondedBy" UUID NULL
CREATE TABLE IF NOT EXISTS public."Games"
(
id uuid NOT NULL DEFAULT uuid_generate_v4(),
gamecode character varying(10) COLLATE pg_catalog."default" NOT NULL,
maxplayers integer NOT NULL,
logintype integer NOT NULL DEFAULT 0,
boardsize integer NOT NULL DEFAULT 50,
"createdBy" uuid NOT NULL,
organizationid uuid,
decks jsonb NOT NULL DEFAULT '[]'::jsonb,
playerids uuid[] NOT NULL DEFAULT '{}'::uuid[],
"winnerId" uuid,
state integer NOT NULL DEFAULT 0,
"createDate" timestamp without time zone NOT NULL DEFAULT now(),
start_date timestamp without time zone,
"finishDate" timestamp without time zone,
"updateDate" timestamp without time zone NOT NULL DEFAULT now(),
"organizationId" uuid,
CONSTRAINT "PK_1950492f583d31609c5e9fbbe12" PRIMARY KEY (id),
CONSTRAINT "UQ_9d52c646079cbe6f242a85c5c41" UNIQUE (gamecode)
);
-- Create Games table
CREATE TABLE "Games" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"gamecode" VARCHAR(10) UNIQUE NOT NULL,
"maxplayers" INTEGER NOT NULL,
"logintype" INTEGER NOT NULL DEFAULT 0,
"state" INTEGER NOT NULL DEFAULT 0,
"playerids" UUID[] NOT NULL DEFAULT '{}',
"decks" JSONB NOT NULL DEFAULT '[]',
"boardsize" INTEGER NOT NULL DEFAULT 50,
"createDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updateDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishDate" TIMESTAMP NULL,
"winnerid" UUID NULL,
"createdBy" UUID NOT NULL,
"organizationid" UUID NULL
CREATE TABLE IF NOT EXISTS public."Organizations"
(
id uuid NOT NULL DEFAULT uuid_generate_v4(),
name character varying(255) COLLATE pg_catalog."default" NOT NULL,
contactfname character varying(100) COLLATE pg_catalog."default" NOT NULL,
contactlname character varying(100) COLLATE pg_catalog."default" NOT NULL,
contactphone character varying(20) COLLATE pg_catalog."default" NOT NULL,
contactemail character varying(255) COLLATE pg_catalog."default" NOT NULL,
state integer NOT NULL DEFAULT 0,
regdate timestamp without time zone NOT NULL DEFAULT now(),
"updateDate" timestamp without time zone NOT NULL DEFAULT now(),
url character varying(500) COLLATE pg_catalog."default",
userinorg integer NOT NULL DEFAULT 0,
"maxOrganizationalDecks" integer,
CONSTRAINT "PK_e0690a31419f6666194423526f2" PRIMARY KEY (id)
);
-- Add Foreign Key Constraints
ALTER TABLE "Users"
ADD CONSTRAINT "FK_Users_Organizations"
FOREIGN KEY ("orgid") REFERENCES "Organizations"("id") ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS public."Users"
(
id uuid NOT NULL DEFAULT uuid_generate_v4(),
orgid uuid,
username character varying(100) COLLATE pg_catalog."default" NOT NULL,
password character varying(255) COLLATE pg_catalog."default" NOT NULL,
email character varying(255) COLLATE pg_catalog."default" NOT NULL,
fname character varying(100) COLLATE pg_catalog."default" NOT NULL,
lname character varying(100) COLLATE pg_catalog."default" NOT NULL,
token character varying(255) COLLATE pg_catalog."default",
"TokenExpires" timestamp without time zone,
phone character varying(20) COLLATE pg_catalog."default",
state integer NOT NULL DEFAULT 0,
regdate timestamp without time zone NOT NULL DEFAULT now(),
"updateDate" timestamp without time zone NOT NULL DEFAULT now(),
"Orglogindate" timestamp without time zone,
CONSTRAINT "PK_16d4f7d636df336db11d87413e3" PRIMARY KEY (id),
CONSTRAINT "UQ_3c3ab3f49a87e6ddb607f3c4945" UNIQUE (email),
CONSTRAINT "UQ_ffc81a3b97dcbf8e320d5106c0d" UNIQUE (username)
);
ALTER TABLE "Decks"
ADD CONSTRAINT "FK_Decks_Users"
FOREIGN KEY ("user_id") REFERENCES "Users"("id") ON DELETE CASCADE;
CREATE TABLE IF NOT EXISTS public.migrations
(
id serial NOT NULL,
"timestamp" bigint NOT NULL,
name character varying COLLATE pg_catalog."default" NOT NULL,
CONSTRAINT "PK_8c82d7f526340ab734260ea46be" PRIMARY KEY (id)
);
ALTER TABLE "Decks"
ADD CONSTRAINT "FK_Decks_Organizations"
FOREIGN KEY ("organization_id") REFERENCES "Organizations"("id") ON DELETE SET NULL;
ALTER TABLE IF EXISTS public."Decks"
ADD CONSTRAINT "FK_06ee28f90d68543a03b14aebe13" FOREIGN KEY (organization_id)
REFERENCES public."Organizations" (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE NO ACTION;
ALTER TABLE "Contacts"
ADD CONSTRAINT "FK_Contacts_Users"
FOREIGN KEY ("userid") REFERENCES "Users"("id") ON DELETE SET NULL;
ALTER TABLE "Contacts"
ADD CONSTRAINT "FK_Contacts_RespondedBy"
FOREIGN KEY ("respondedBy") REFERENCES "Users"("id") ON DELETE SET NULL;
ALTER TABLE IF EXISTS public."Decks"
ADD CONSTRAINT "FK_a39059433e29882e1309d3a5e70" FOREIGN KEY (user_id)
REFERENCES public."Users" (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE NO ACTION;
ALTER TABLE "Chats"
ADD CONSTRAINT "FK_Chats_CreatedBy"
FOREIGN KEY ("createdBy") REFERENCES "Users"("id") ON DELETE SET NULL;
ALTER TABLE "Chats"
ADD CONSTRAINT "FK_Chats_Games"
FOREIGN KEY ("gameId") REFERENCES "Games"("id") ON DELETE SET NULL;
ALTER TABLE IF EXISTS public."Games"
ADD CONSTRAINT "FK_330362bff8b25bb573f31fb4023" FOREIGN KEY ("winnerId")
REFERENCES public."Users" (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE NO ACTION;
ALTER TABLE "Games"
ADD CONSTRAINT "FK_Games_CreatedBy"
FOREIGN KEY ("createdBy") REFERENCES "Users"("id") ON DELETE CASCADE;
ALTER TABLE "Games"
ADD CONSTRAINT "FK_Games_Organizations"
FOREIGN KEY ("organizationid") REFERENCES "Organizations"("id") ON DELETE SET NULL;
ALTER TABLE IF EXISTS public."Games"
ADD CONSTRAINT "FK_e3c4e8898fa026a5551aefc4f62" FOREIGN KEY ("organizationId")
REFERENCES public."Organizations" (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE NO ACTION;
ALTER TABLE "Games"
ADD CONSTRAINT "FK_Games_Winner"
FOREIGN KEY ("winnerid") REFERENCES "Users"("id") ON DELETE SET NULL;
-- Create Indexes for Performance
CREATE INDEX "IDX_Users_Username" ON "Users" ("username");
CREATE INDEX "IDX_Users_Email" ON "Users" ("email");
CREATE INDEX "IDX_Users_OrgId" ON "Users" ("orgid");
CREATE INDEX "IDX_Users_State" ON "Users" ("state");
ALTER TABLE IF EXISTS public."Games"
ADD CONSTRAINT "FK_f32db60863a8a393b30aa222cd5" FOREIGN KEY ("createdBy")
REFERENCES public."Users" (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE NO ACTION;
CREATE INDEX "IDX_Organizations_Name" ON "Organizations" ("name");
CREATE INDEX "IDX_Organizations_State" ON "Organizations" ("state");
CREATE INDEX "IDX_Decks_UserId" ON "Decks" ("user_id");
CREATE INDEX "IDX_Decks_Type" ON "Decks" ("type");
CREATE INDEX "IDX_Decks_CType" ON "Decks" ("ctype");
CREATE INDEX "IDX_Decks_State" ON "Decks" ("state");
CREATE INDEX "IDX_Decks_OrganizationId" ON "Decks" ("organization_id");
CREATE INDEX "IDX_Chats_Type" ON "Chats" ("type");
CREATE INDEX "IDX_Chats_State" ON "Chats" ("state");
CREATE INDEX "IDX_Chats_GameId" ON "Chats" ("gameId");
CREATE INDEX "IDX_Chats_CreatedBy" ON "Chats" ("createdBy");
CREATE INDEX "IDX_Contacts_Type" ON "Contacts" ("type");
CREATE INDEX "IDX_Contacts_State" ON "Contacts" ("state");
CREATE INDEX "IDX_Contacts_UserId" ON "Contacts" ("userid");
CREATE INDEX "IDX_Games_GameCode" ON "Games" ("gamecode");
CREATE INDEX "IDX_Games_State" ON "Games" ("state");
CREATE INDEX "IDX_Games_CreatedBy" ON "Games" ("createdBy");
CREATE INDEX "IDX_Games_OrganizationId" ON "Games" ("organizationid");
-- Comments for documentation
COMMENT ON TABLE "Users" IS 'User accounts with authentication and profile information';
COMMENT ON TABLE "Organizations" IS 'Organizations that can have multiple users and premium features';
COMMENT ON TABLE "Decks" IS 'Card decks for the game, can be public, private, or organizational';
COMMENT ON TABLE "Chats" IS 'Chat system supporting direct messages, groups, and game chats';
COMMENT ON TABLE "Contacts" IS 'Contact form submissions and support tickets';
COMMENT ON TABLE "Games" IS 'Game sessions with players, decks, and game state';
-- Enum value comments
COMMENT ON COLUMN "Users"."state" IS '0=REGISTERED_NOT_VERIFIED, 1=VERIFIED_REGULAR, 2=VERIFIED_PREMIUM, 3=SOFT_DELETE, 4=DEACTIVATED, 5=ADMIN';
COMMENT ON COLUMN "Organizations"."state" IS '0=REGISTERED, 1=ACTIVE, 2=SOFT_DELETE';
COMMENT ON COLUMN "Decks"."type" IS '0=LUCK, 1=JOKER, 2=QUESTION';
COMMENT ON COLUMN "Decks"."ctype" IS '0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION';
COMMENT ON COLUMN "Decks"."state" IS '0=ACTIVE, 1=SOFT_DELETE';
COMMENT ON COLUMN "Chats"."type" IS 'direct, group, game';
COMMENT ON COLUMN "Chats"."state" IS '0=ACTIVE, 1=ARCHIVE, 2=SOFT_DELETE';
COMMENT ON COLUMN "Contacts"."type" IS '0=BUG, 1=PROBLEM, 2=QUESTION, 3=SALES, 4=OTHER';
COMMENT ON COLUMN "Contacts"."state" IS '0=ACTIVE, 1=RESOLVED, 2=SOFT_DELETE';
COMMENT ON COLUMN "Games"."state" IS '0=WAITING, 1=ACTIVE, 2=FINISHED, 3=CANCELLED';
COMMENT ON COLUMN "Games"."logintype" IS '0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION';
-- Grant permissions for application user
-- Note: Replace 'serpentrace_app' with your actual application database user
-- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO serpentrace_app;
-- GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO serpentrace_app;
-- GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO serpentrace_app;
END;
+128 -1
View File
@@ -16,6 +16,7 @@
"react-icons": "^5.5.0",
"react-router-dom": "^7.6.0",
"react-toastify": "^11.0.5",
"socket.io-client": "^4.8.1",
"tailwindcss": "^4.1.7"
},
"devDependencies": {
@@ -1259,6 +1260,11 @@
"win32"
]
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/@tailwindcss/node": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz",
@@ -1957,6 +1963,42 @@
"dev": true,
"license": "ISC"
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
@@ -3097,7 +3139,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -3478,6 +3519,64 @@
"node": ">=8"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3729,6 +3828,34 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1
View File
@@ -18,6 +18,7 @@
"react-icons": "^5.5.0",
"react-router-dom": "^7.6.0",
"react-toastify": "^11.0.5",
"socket.io-client": "^4.8.1",
"tailwindcss": "^4.1.7"
},
"devDependencies": {
+2
View File
@@ -14,6 +14,7 @@ import CompanyHub from "./pages/Contacts/Contacts"
import About from "./pages/About/About"
import ScrollToTop from "./components/ScrollToTop"
import GameScreen from "./pages/Game/GameScreen"
import GameTest from "./pages/Game/GameTest"
import Reports from "./pages/Report/Reports"
import Lobby from "./pages/Game/Lobby"
import ProfileCard from "./components/Userdetails/Userdetails"
@@ -68,6 +69,7 @@ function App() {
<Route path="/deck-creator" element={<DeckCreator />} />
<Route path="/deck-creator/:deckId" element={<DeckCreator />} />
<Route path="/game" element={<GameScreen />} />
<Route path="/game-test" element={<GameTest />} />
{/* <Route path="/contacts" element={<CompanyHub />} /> */}
<Route path="/report" element={<Reports />} />
<Route path="/choosedeck" element={<ChooseDeck />} />
+80
View File
@@ -0,0 +1,80 @@
import { apiClient } from './userApi';
/**
* Create a new game
* @param {Object} gameData - Game creation data
* @param {string[]} gameData.deckids - Array of deck UUIDs
* @param {number} gameData.maxplayers - Maximum players (2-8)
* @param {number} gameData.logintype - 0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION
* @returns {Promise<Object>} Game data with gameCode
*/
export const createGame = async (gameData) => {
try {
const response = await apiClient.post('/games/start', gameData);
return response.data;
} catch (error) {
console.error('Error creating game:', error);
throw error;
}
};
/**
* Join an existing game
* @param {Object} joinData - Join game data
* @param {string} joinData.gameCode - 6-character game code
* @param {string} [joinData.playerName] - Player name (required for public games)
* @returns {Promise<Object>} Game data with gameToken
*/
export const joinGame = async (joinData) => {
try {
const response = await apiClient.post('/games/join', joinData);
return response.data;
} catch (error) {
console.error('Error joining game:', error);
console.error('Join game error response:', error.response?.data);
throw error;
}
};
/**
* Start the game (gamemaster only)
* @param {string} gameId - Game UUID
* @returns {Promise<Object>} Game data with board
*/
export const startGame = async (gameId) => {
try {
const response = await apiClient.post(`/games/${gameId}/start`);
return response.data;
} catch (error) {
console.error('Error starting game:', error);
throw error;
}
};
/**
* Get user's games
* @returns {Promise<Array>} Array of games
*/
export const getMyGames = async () => {
try {
const response = await apiClient.get('/games/my-games');
return response.data;
} catch (error) {
console.error('Error fetching games:', error);
throw error;
}
};
/**
* Get active public games
* @returns {Promise<Array>} Array of active games
*/
export const getActiveGames = async () => {
try {
const response = await apiClient.get('/games/active');
return response.data;
} catch (error) {
console.error('Error fetching active games:', error);
throw error;
}
};
@@ -0,0 +1,268 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { io } from 'socket.io-client';
import { API_CONFIG } from '../api/userApi';
const isDev = import.meta.env.DEV;
const log = (...args) => isDev && console.log(...args);
const warn = (...args) => isDev && console.warn(...args);
const error = (...args) => console.error(...args);
/**
* Optimized WebSocket hook for game connection
* @param {string} gameToken - JWT token from game join
* @returns {Object} WebSocket state and methods
*/
export const useGameWebSocket = (gameToken) => {
const socketRef = useRef(null);
const [isConnected, setIsConnected] = useState(false);
const [gameState, setGameState] = useState(null);
const [boardData, setBoardData] = useState(null);
const [error, setError] = useState(null);
const [isGamemaster, setIsGamemaster] = useState(false);
const [gameStarted, setGameStarted] = useState(false);
const eventListenersRef = useRef(new Map());
// Memoized derived values - no extra state needed
const players = useMemo(() => {
// Backend sends different player fields depending on game state
// connectedPlayers: array of player names (strings) who are connected via WebSocket
// players: full player objects with game data (positions, etc.)
const connectedPlayers = gameState?.connectedPlayers || [];
const gamePlayers = gameState?.players || [];
const currentPlayers = gameState?.currentPlayers || [];
// If we have full player objects, use those
if (currentPlayers.length > 0) return currentPlayers;
if (gamePlayers.length > 0) return gamePlayers;
// Otherwise, map connected player names to basic player objects
return connectedPlayers.map((name, index) => ({
id: `player-${index}`,
name: typeof name === 'string' ? name : name.playerName || `Player ${index + 1}`,
isOnline: true,
isReady: gameState?.readyPlayers?.includes(name) || false,
}));
}, [gameState?.connectedPlayers, gameState?.players, gameState?.currentPlayers, gameState?.readyPlayers]);
const currentTurn = useMemo(() => gameState?.currentPlayer || null, [gameState?.currentPlayer]);
// Connect to game WebSocket - only once per token
useEffect(() => {
if (!gameToken) return;
log('🔌 Connecting to game WebSocket...');
// Connect to /game namespace
socketRef.current = io(`${API_CONFIG.wsURL}/game`, {
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
timeout: 5000,
});
const socket = socketRef.current;
// Connection handlers
const handleConnect = () => {
log('✅ Connected to game WebSocket');
setIsConnected(true);
setError(null);
socket.emit('game:join', { gameToken });
};
const handleConnectError = (err) => {
error('❌ Connection error:', err);
setIsConnected(false);
setError(err.message);
};
const handleDisconnect = (reason) => {
log('🔌 Disconnected:', reason);
setIsConnected(false);
};
// Game state handlers - batch updates
const handleGameState = (state) => {
log('📊 Game state:', state);
setGameState(state);
};
const handleGameJoined = (data) => {
log('✅ Joined game:', data);
// Store if this user is the gamemaster
if (data.isGamemaster !== undefined) {
setIsGamemaster(data.isGamemaster);
}
// Backend will send game:state next
};
const handlePlayerJoined = (data) => {
log('👤 Player joined:', data.playerName);
// Update game state to add the new player to connectedPlayers
setGameState(prev => {
if (!prev) return prev;
const currentConnected = prev.connectedPlayers || [];
// Only add if not already in the list
if (!currentConnected.includes(data.playerName)) {
return {
...prev,
connectedPlayers: [...currentConnected, data.playerName]
};
}
return prev;
});
};
const handleGameStarted = (data) => {
log('🎮 Game started:', data);
// Batch state updates
if (data.boardData) setBoardData(data.boardData);
if (data.gameState) setGameState(data.gameState);
// Signal that game has started
setGameStarted(true);
};
const handlePlayerMoved = (moveData) => {
log('🏃 Player moved:', moveData.playerName);
// Update only the moved player
setGameState(prev => {
if (!prev?.currentPlayers) return prev;
return {
...prev,
currentPlayers: prev.currentPlayers.map(p =>
p.playerId === moveData.playerId
? { ...p, boardPosition: moveData.newPosition }
: p
),
};
});
};
const handleTurnChanged = (data) => {
log('🔄 Turn changed to:', data.currentPlayerName);
setGameState(prev => prev ? { ...prev, currentPlayer: data.currentPlayer } : prev);
};
const handleError = (err) => {
error('❌ Game error:', err);
setError(err.message);
};
// Register all handlers
socket.on('connect', handleConnect);
socket.on('connect_error', handleConnectError);
socket.on('disconnect', handleDisconnect);
socket.on('game:state', handleGameState);
socket.on('game:state-update', handleGameState);
socket.on('game:joined', handleGameJoined);
socket.on('game:player-joined', handlePlayerJoined);
socket.on('game:started', handleGameStarted);
socket.on('game:player-moved', handlePlayerMoved);
socket.on('game:turn-changed', handleTurnChanged);
socket.on('game:error', handleError);
// Cleanup
return () => {
log('🧹 Cleaning up WebSocket connection');
socket.removeAllListeners();
socket.disconnect();
};
}, [gameToken]);
// Optimized event listener management
const addEventListener = useCallback((event, handler) => {
const socket = socketRef.current;
if (!socket) return;
socket.on(event, handler);
eventListenersRef.current.set(event, handler);
}, []);
const removeEventListener = useCallback((event) => {
const socket = socketRef.current;
if (!socket) return;
const handler = eventListenersRef.current.get(event);
if (handler) {
socket.off(event, handler);
eventListenersRef.current.delete(event);
}
}, []);
// Memoized action methods - stable references
const rollDice = useCallback((diceValue) => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot roll dice: not connected');
return false;
}
log('🎲 Rolling dice:', diceValue);
socket.emit('game:dice-roll', {
gameCode: gameState?.gameCode,
diceValue,
});
return true;
}, [isConnected, gameState?.gameCode]);
const sendMessage = useCallback((message) => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot send message: not connected');
return false;
}
socket.emit('game:chat', {
gameCode: gameState?.gameCode,
message,
});
return true;
}, [isConnected, gameState?.gameCode]);
const setReady = useCallback((ready = true) => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot set ready: not connected');
return false;
}
socket.emit('game:ready', {
gameCode: gameState?.gameCode,
ready,
});
return true;
}, [isConnected, gameState?.gameCode]);
const leaveGame = useCallback(() => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot leave game: not connected');
return false;
}
socket.emit('game:leave', {
gameCode: gameState?.gameCode,
});
return true;
}, [isConnected, gameState?.gameCode]);
return {
socket: socketRef.current,
isConnected,
gameState,
players,
boardData,
currentTurn,
error,
isGamemaster,
gameStarted,
// Methods
rollDice,
sendMessage,
setReady,
leaveGame,
addEventListener,
removeEventListener,
};
};
+273 -110
View File
@@ -1,88 +1,189 @@
import React, { useState } from "react"
import React, { useState, useEffect, useMemo, useCallback } from "react"
import { getVerticalOffset } from "../../utils/randomUtils"
import Dice from "../../utils/dice/Dice"
import { useGameWebSocket } from "../../hooks/useGameWebSocket"
// Constants - outside component to prevent recreation
const PLAYER_STYLES = [
{ color: "bg-blue-600", emoji: "🐍" },
{ color: "bg-green-600", emoji: "🐢" },
{ color: "bg-purple-600", emoji: "🐇" },
{ color: "bg-yellow-600", emoji: "🦊" },
{ color: "bg-red-600", emoji: "🦁" },
{ color: "bg-pink-600", emoji: "🐷" },
{ color: "bg-orange-600", emoji: "🐯" },
{ color: "bg-indigo-600", emoji: "🐺" },
]
const BOARD_CONFIG = {
rows: 5,
cols: 20,
cellSize: 40,
cellMargin: 2.5,
rowSpacing: 70,
}
// Helper functions outside component
const mapFieldType = (backendType) => {
switch (backendType) {
case 'positive': return 'good'
case 'negative': return 'bad'
case 'luck': return 'clover'
default: return 'regular'
}
}
const getDefaultFieldType = (count) => {
if (count % 17 === 0) return "clover"
if (count % 13 === 0) return "bad"
if ((count + 5) % 13 === 0) return "good"
return "regular"
}
const GameScreen = () => {
const boardRows = 5
const boardCols = 20
const totalCells = boardRows * boardCols
const cellSize = 40
const cellMargin = 2.5
const rowSpacing = 70 // Extra spacing between rows
const topOffset = rowSpacing * 0.5 // Increase topOffset for more spacing
const bottomOffset = rowSpacing * 0.5 // Increase bottomOffset for more spacing
const boardWidthPx = boardCols * (cellSize + cellMargin * 2)
const boardHeightPx =
boardRows * (cellSize + cellMargin * 2 + rowSpacing) + topOffset + bottomOffset - rowSpacing
// WebSocket connection
const gameToken = localStorage.getItem('gameToken')
const {
isConnected,
gameState,
players: backendPlayers,
boardData,
currentTurn,
error,
rollDice,
addEventListener,
removeEventListener
} = useGameWebSocket(gameToken)
// Generate a snake-like path with vertical spacing and vertical offsets
const generateWindingPath = () => {
const [path, setPath] = useState([])
const [players, setPlayers] = useState([])
// Memoized board dimensions
const { rows, cols, totalCells, cellSize, cellMargin, rowSpacing, topOffset, width, height } = useMemo(() => {
const { rows, cols, cellSize, cellMargin, rowSpacing } = BOARD_CONFIG
const topOffset = rowSpacing * 0.5
const bottomOffset = rowSpacing * 0.5
const totalCells = rows * cols
return {
rows,
cols,
totalCells,
cellSize,
cellMargin,
rowSpacing,
topOffset,
bottomOffset,
width: cols * (cellSize + cellMargin * 2),
height: rows * (cellSize + cellMargin * 2 + rowSpacing) + topOffset + bottomOffset - rowSpacing,
}
}, [])
// Memoized path generator - Snake pattern with proper turn handling
const generateWindingPath = useCallback((backendFields = null) => {
const path = []
const hasBackendData = backendFields && Array.isArray(backendFields)
let currentNum = 1
for (let row = 0; row < boardRows && currentNum <= totalCells; row++) {
// Calculate the y position with extra row spacing
const baseYPosition = topOffset + row * (cellSize + cellMargin * 2 + rowSpacing)
// Generate all 100 positions
while (currentNum <= totalCells) {
const row = Math.floor((currentNum - 1) / cols)
const posInRow = (currentNum - 1) % cols
const isLeftToRight = row % 2 === 0
// If row number is even, go right; if odd, go left
if (row % 2 === 0) {
// Left to right
for (let col = 0; col < boardCols && currentNum <= totalCells; col++) {
path.push({
number: currentNum++,
x: col * (cellSize + cellMargin * 2),
y: baseYPosition + getVerticalOffset(currentNum - 1),
type: getFieldType(currentNum - 1),
})
}
} else {
// Right to left
for (let col = boardCols - 1; col >= 0 && currentNum <= totalCells; col--) {
path.push({
number: currentNum++,
x: col * (cellSize + cellMargin * 2),
y: baseYPosition + getVerticalOffset(currentNum - 1),
type: getFieldType(currentNum - 1),
})
}
// Calculate column based on direction
const col = isLeftToRight ? posInRow : (cols - 1 - posInRow)
// Base Y position for this row
let baseYPosition = topOffset + row * (cellSize + cellMargin * 2 + rowSpacing)
// Apply vertical offset for wave effect
let yOffset = getVerticalOffset(currentNum - 1)
// Special handling for turn positions (21, 41, 61, 81)
// These should be positioned between rows to show the turn
if (currentNum % cols === 1 && currentNum > 1) {
// This is the first element of a new row (21, 41, 61, 81)
// Position it halfway between the previous row and current row
baseYPosition = topOffset + (row - 0.5) * (cellSize + cellMargin * 2 + rowSpacing)
yOffset = 0 // Reset wave offset for turn positions
}
const backendField = hasBackendData ? backendFields.find(f => f.position === currentNum) : null
path.push({
number: currentNum,
x: col * (cellSize + cellMargin * 2),
y: baseYPosition + yOffset,
type: backendField ? mapFieldType(backendField.type) : getDefaultFieldType(currentNum - 1),
stepValue: backendField?.stepValue || 0,
})
currentNum++
}
return path
}
}, [rows, cols, totalCells, cellSize, cellMargin, rowSpacing, topOffset])
const getFieldType = (count) => {
if (count % 17 === 0) return "clover"
if (count % 13 === 0) return "bad"
if ((count + 5) % 13 === 0) return "good"
return "regular"
}
// Update path when boardData changes
useEffect(() => {
if (boardData?.fields) {
setPath(generateWindingPath(boardData.fields))
} else if (path.length === 0) {
setPath(generateWindingPath())
}
}, [boardData, generateWindingPath])
const [path, setPath] = useState(generateWindingPath())
const [players, setPlayers] = useState([
{ id: 1, name: "Béla", position: 34, score: 25, color: "bg-blue-600", emoji: "🐍" },
{ id: 2, name: "Juci", position: 50, score: 30, color: "bg-green-600", emoji: "🐢" },
{ id: 3, name: "Kati", position: 70, score: 15, color: "bg-purple-600", emoji: "🐇" },
{ id: 3, name: "Fürtös", position: 68, score: 14, color: "bg-yellow-600", emoji: "😂" },
])
// Update players from backend - memoized mapping
useEffect(() => {
if (!backendPlayers?.length) return
// New: selected dice value from dropdown (null = none)
const [selectedDice, setSelectedDice] = useState(null)
const mappedPlayers = backendPlayers.map((player, index) => ({
id: player.playerId || player.id || index,
name: player.playerName || player.name || `Player ${index + 1}`,
position: player.boardPosition || 0,
score: player.score || 0,
color: PLAYER_STYLES[index % PLAYER_STYLES.length].color,
emoji: PLAYER_STYLES[index % PLAYER_STYLES.length].emoji,
isOnline: player.isOnline !== undefined ? player.isOnline : true,
isReady: player.isReady || false,
}))
// Sort players by position in descending order
const sortedPlayers = [...players].sort((a, b) => b.position - a.position)
setPlayers(mappedPlayers)
}, [backendPlayers])
// Handle dice roll completion
const handleDiceRoll = (value) => {
console.log("Rolled:", value)
// reset dropdown selection after roll
setSelectedDice(null)
// You can add logic here to move the current player based on the dice value
}
// Listen to player movement - optimized to update only moved player
useEffect(() => {
if (!addEventListener) return
console.log("Generated path length:", path.length)
const handlePlayerMoved = (moveData) => {
setPlayers(prev =>
prev.map(p =>
p.id === moveData.playerId
? { ...p, position: moveData.newPosition }
: p
)
)
}
const getFieldStyle = (type) => {
addEventListener('game:player-moved', handlePlayerMoved)
return () => removeEventListener('game:player-moved')
}, [addEventListener, removeEventListener])
// Sorted players - memoized
const sortedPlayers = useMemo(
() => [...players].sort((a, b) => b.position - a.position),
[players]
)
// Handle dice roll
const handleDiceRoll = useCallback((value) => {
rollDice(value)
}, [rollDice])
// Get field style - memoized
const getFieldStyle = useCallback((type) => {
switch (type) {
case "clover":
return "bg-teal-700 border-teal-500 shadow-teal-800"
@@ -93,15 +194,16 @@ const GameScreen = () => {
default:
return "bg-gray-800 border-gray-600 shadow-gray-900"
}
}
}, [])
const getPlayerPosition = (playerPosition) => {
// Get player position - memoized
const getPlayerPosition = useCallback((playerPosition) => {
const field = path.find((p) => p.number === playerPosition)
return field ? { top: `${field.y}px`, left: `${field.x}px` } : { top: 0, left: 0 }
}
}, [path])
// Function to get medal style based on rank
const getMedalStyle = (rank) => {
// Get medal style - memoized
const getMedalStyle = useCallback((rank) => {
switch (rank) {
case 1:
return "bg-yellow-400 text-yellow-900 border-yellow-500 shadow-yellow-600"
@@ -112,20 +214,57 @@ const GameScreen = () => {
default:
return "bg-gray-700 text-gray-300 border-gray-600 shadow-gray-800"
}
}
}, [])
return (
<div className="p-4 bg-gradient-to-br from-gray-900 via-gray-800 to-teal-900 min-h-screen flex items-center justify-center">
<div className="w-full">
{/* Connection Status Indicator */}
<div className="fixed top-4 right-4 z-50">
<div className={`px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 ${
isConnected
? 'bg-green-600 text-white'
: 'bg-red-600 text-white'
}`}>
<div className={`w-3 h-3 rounded-full ${
isConnected ? 'bg-green-300 animate-pulse' : 'bg-red-300'
}`}></div>
<span className="text-sm font-medium">
{isConnected ? '🟢 Csatlakozva' : '🔴 Kapcsolódás...'}
</span>
</div>
{error && (
<div className="mt-2 px-4 py-2 rounded-lg shadow-lg bg-red-600 text-white text-xs">
{error}
</div>
)}
</div>
{/* Game Info Bar */}
{gameState && (
<div className="fixed top-4 left-4 z-50">
<div className="bg-gray-800 border border-teal-700 px-4 py-2 rounded-lg shadow-lg">
<div className="text-teal-300 text-sm font-medium">
🎮 Játék kód: <span className="font-bold text-white">{gameState.gameCode || 'N/A'}</span>
</div>
{currentTurn && (
<div className="text-gray-400 text-xs mt-1">
🎯 Köron: <span className="text-white">{players.find(p => p.id === currentTurn)?.name || 'Betöltés...'}</span>
</div>
)}
</div>
</div>
)}
<div className="flex flex-col md:flex-row gap-6 justify-center">
{/* Game Board */}
<div className="relative bg-gray-800 p-6 rounded-2xl shadow-xl border border-teal-700 flex flex-col items-center justify-center overflow-hidden">
{/* Háttér */}
{/* Background decoration */}
<div className="absolute w-full h-full opacity-10 pointer-events-none overflow-hidden">
{[...Array(35)].map((_, i) => (
<div
key={i}
className="absolute rounded-full bg-teal-600 animate-pulse8"
className="absolute rounded-full bg-teal-600 animate-pulse"
style={{
width: Math.random() * 120 + 40 + "px",
height: Math.random() * 120 + 40 + "px",
@@ -136,8 +275,9 @@ const GameScreen = () => {
></div>
))}
</div>
<div className="relative" style={{ height: `${boardHeightPx}px`, width: `${boardWidthPx}px` }}>
{/* Mezők */}
<div className="relative" style={{ height: `${height}px`, width: `${width}px` }}>
{/* Fields */}
{path.map((field) => (
<div
key={field.number}
@@ -163,44 +303,65 @@ const GameScreen = () => {
className={`absolute w-6 h-6 ${player.color} rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold z-10 animate-bounce`}
style={{
...getPlayerPosition(player.position),
transform: "translate(18px, 18px)",
transform: "translate(17px, 17px)",
}}
>
{player.emoji}
</div>
))}
</div>
{/* Game information */}
{/* <div className="bg-white rounded-xl p-2 shadow-lg border border-indigo-100 max-w-3xl mx-auto mt-4 z-10">
<p className="text-gray-600 text-sm text-center">
<span className="inline-flex items-center mx-2"><span className="w-3 h-3 bg-white border border-gray-300 rounded-full mr-1"></span> Sima</span>
<span className="inline-flex items-center mx-2"><span className="w-3 h-3 bg-green-200 border border-green-500 rounded-full mr-1"></span> Lóhere</span>
<span className="inline-flex items-center mx-2"><span className="w-3 h-3 bg-red-200 border border-red-500 rounded-full mr-1"></span> Rossz</span>
<span className="inline-flex items-center mx-2"><span className="w-3 h-3 bg-blue-200 border border-blue-500 rounded-full mr-1"></span> Jó</span>
</p>
</div> */}
</div>
{/* Right sidebar */}
<div className="flex-1 max-w-md">
<div className="bg-gray-800 rounded-xl p-4 shadow-lg mb-4 border border-teal-700">
<h2 className="text-xl font-semibold mb-3 text-teal-300">Játékosok</h2>
{/* Empty state */}
{players.length === 0 && (
<div className="text-center py-8 text-gray-400">
<div className="text-4xl mb-2">👥</div>
<p className="text-sm">Várakozás játékosokra...</p>
</div>
)}
{/* Players list */}
{sortedPlayers.map((player, index) => (
<div
key={player.id}
className="flex items-center mb-3 p-2 bg-gray-900 rounded-lg hover:bg-gray-700 transition-colors"
className="flex items-center mb-3 p-2 bg-gray-900 rounded-lg hover:bg-gray-700 transition-colors relative"
>
{/* Online indicator */}
{player.isOnline && (
<div className="absolute top-1 right-1 w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
)}
<div
className={`w-8 h-8 ${player.color} rounded-full mr-3 flex items-center justify-center text-white text-sm font-bold shadow-md`}
>
{player.emoji}
</div>
<div className="flex-1">
<div className="font-medium text-sm text-gray-300 flex items-center">
<div className="font-medium text-sm text-gray-300 flex items-center gap-2 flex-wrap">
{player.name}
{/* Ready indicator */}
{player.isReady && (
<span className="px-2 py-0.5 bg-green-600 text-white text-xs rounded-full">
Kész
</span>
)}
{/* Current turn indicator */}
{currentTurn === player.id && (
<span className="px-2 py-0.5 bg-yellow-500 text-gray-900 text-xs rounded-full font-bold animate-pulse">
Köre
</span>
)}
{/* Rank medal */}
<span
className={`ml-2 px-2 py-1 rounded-full border text-xs font-bold shadow-md ${getMedalStyle(
className={`ml-auto px-2 py-1 rounded-full border text-xs font-bold shadow-md ${getMedalStyle(
index + 1
)}`}
>
@@ -225,31 +386,33 @@ const GameScreen = () => {
<div className="bg-gray-800 rounded-xl p-4 shadow-lg border border-teal-700 text-center">
<h2 className="text-xl font-semibold mb-3 text-teal-300">Dobókocka</h2>
<p className="text-gray-300 text-sm mb-4">
Kattints a kockára dobáshoz vagy válassz egy számot az alábbiból!
Kattints a kockára dobáshoz!
</p>
{/* Dropdown to select number 1-6 (triggers animated roll to that number) */}
<div className="mb-3">
<select
value={selectedDice ?? ""}
onChange={(e) => {
const v = e.target.value ? Number(e.target.value) : null
setSelectedDice(v)
}}
className="bg-gray-900 text-gray-200 rounded-md p-2 border border-gray-700"
>
<option value="">Válassz számot...</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</div>
<Dice onRoll={handleDiceRoll} />
<Dice onRoll={handleDiceRoll} selectedValue={selectedDice} />
{/* Connection warning */}
{!isConnected && (
<div className="mt-3 text-xs text-red-400">
Nincs kapcsolat a szerverrel
</div>
)}
</div>
{/* Debug Info Panel (Development only) */}
{import.meta.env.DEV && (
<div className="bg-gray-900 rounded-xl p-4 shadow-lg border border-gray-700 text-left mt-4">
<h3 className="text-sm font-semibold mb-2 text-gray-400">🔧 Debug Info</h3>
<div className="text-xs text-gray-500 space-y-1">
<div>📡 Connected: {isConnected ? '✅' : '❌'}</div>
<div>🎮 Game Code: {gameState?.gameCode || 'N/A'}</div>
<div>👥 Players: {backendPlayers?.length || 0}</div>
<div>🎲 Board Fields: {boardData?.fields?.length || 0}</div>
<div>🏁 Current Turn: {currentTurn || 'N/A'}</div>
<div>🔑 Token: {gameToken ? '✅' : '❌'}</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -0,0 +1,160 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createGame, joinGame } from '../../api/gameApi';
const GameTest = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [gameCode, setGameCode] = useState('');
const [createdGameCode, setCreatedGameCode] = useState('');
const [showSuccess, setShowSuccess] = useState(false);
const handleCreateGame = async () => {
setLoading(true);
setError(null);
setShowSuccess(false);
try {
const token = localStorage.getItem('token');
if (!token) {
setError('Please login first at /login');
return;
}
const gameData = {
deckids: ['99333c9a-5928-4788-b852-fa482d34ce56'], // Test deck ID as array
maxplayers: 4,
logintype: 0, // 0=PUBLIC
};
const response = await createGame(gameData);
console.log('Game created:', response);
// Backend returns game object directly
const code = response.gamecode || response.gameCode;
if (code) {
setCreatedGameCode(code);
setShowSuccess(true);
}
// Store game token if provided
if (response.gameToken) {
localStorage.setItem('gameToken', response.gameToken);
}
// Wait 3 seconds to show code, then navigate
setTimeout(() => {
navigate('/lobby', { state: { gameCode: code } });
}, 3000);
} catch (err) {
setError(err.response?.data?.message || 'Failed to create game');
console.error('Create game error:', err);
} finally {
setLoading(false);
}
};
const handleJoinGame = async () => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('token');
if (!token) {
setError('Kérlek jelentkezz be először a /login oldalon');
return;
}
const joinData = {
gameCode: gameCode.toUpperCase(),
playerName: localStorage.getItem('username') || 'Test Player',
};
const response = await joinGame(joinData);
console.log('Joined game:', response);
// Store game token
if (response.data?.gameToken) {
localStorage.setItem('gameToken', response.data.gameToken);
navigate('/lobby', { state: { gameCode: gameCode.toUpperCase() } });
}
} catch (err) {
setError(err.response?.data?.message || 'Nem sikerült csatlakozni a játékhoz');
console.error('Join game error:', err);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center p-8">
<div className="bg-gray-800 rounded-lg p-8 max-w-md w-full">
<h1 className="text-3xl font-bold mb-6 text-center">Game Test</h1>
{error && (
<div className="bg-red-500/20 border border-red-500 rounded p-3 mb-4">
{error}
</div>
)}
{showSuccess && createdGameCode && (
<div className="bg-green-500/20 border border-green-500 rounded p-4 mb-4">
<p className="font-bold text-lg mb-2">Game Created!</p>
<p className="text-2xl font-mono tracking-wider text-green-400 mb-2">
{createdGameCode}
</p>
<p className="text-sm text-gray-300">
Share this code with other players so they can join!
</p>
<p className="text-sm text-gray-400 mt-2">
Redirecting to game in 3 seconds...
</p>
</div>
)}
<div className="space-y-4">
<button
onClick={handleCreateGame}
disabled={loading}
className="w-full bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white font-bold py-3 px-4 rounded transition"
>
{loading ? 'Creating...' : 'Create New Game'}
</button>
<div className="text-center text-gray-400">OR</div>
<div>
<input
type="text"
value={gameCode}
onChange={(e) => setGameCode(e.target.value)}
placeholder="Enter Game Code"
className="w-full bg-gray-700 text-white px-4 py-2 rounded mb-2"
/>
<button
onClick={handleJoinGame}
disabled={loading || !gameCode}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white font-bold py-3 px-4 rounded transition"
>
{loading ? 'Joining...' : 'Join Game'}
</button>
</div>
</div>
<div className="mt-6 pt-6 border-t border-gray-700">
<p className="text-sm text-gray-400 mb-2">Quick Access (Dev Only):</p>
<button
onClick={() => {
localStorage.setItem('gameToken', 'test-token-123');
navigate('/game');
}}
className="w-full bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded text-sm"
>
Go to Game (with test token)
</button>
</div>
</div>
</div>
);
};
export default GameTest;
+166 -14
View File
@@ -3,6 +3,8 @@ import { useNavigate, useLocation } from "react-router-dom"
import Navbar from "../../components/Navbar/Navbar.jsx"
import Background from "../../assets/backgrounds/Background.jsx"
import useRequireAuth from "../../hooks/useRequireAuth.jsx"
import { useGameWebSocket } from "../../hooks/useGameWebSocket.js"
import { startGame } from "../../api/gameApi.js"
const Lobby = () => {
const [visible, setVisible] = useState(false)
@@ -12,6 +14,30 @@ const Lobby = () => {
const [user, setUser] = useRequireAuth()
// Get game code from location state or WebSocket
const gameCodeFromState = location.state?.gameCode
const gameToken = localStorage.getItem('gameToken')
const {
isConnected,
gameState,
players,
isGamemaster,
gameStarted,
} = useGameWebSocket(gameToken)
const gameCode = gameCodeFromState || gameState?.gameCode || 'Loading...'
// Filter out gamemaster from player list - gamemaster is NOT a player
const currentPlayers = (players || []).filter(p => {
// If we have userId info, filter by that
if (p.userId) {
return p.userId !== gameState?.createdBy
}
// Otherwise filter by name (less reliable but works for now)
return true
})
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
@@ -23,12 +49,48 @@ const Lobby = () => {
return () => observer.disconnect()
}, [])
// Auto-navigate when game starts
useEffect(() => {
if (gameStarted) {
console.log('🎮 Game started, navigating to /game')
navigate("/game")
}
}, [gameStarted, navigate])
const handleExit = () => {
if (window.confirm("Biztosan ki szeretnél lépni a lobbyból?")) {
localStorage.removeItem('gameToken')
navigate("/home")
}
}
const handleStartGame = async () => {
try {
// Get gameId from gameState
const gameId = gameState?.gameId
if (!gameId) {
alert('Hiba: Játék azonosító nem található')
return
}
console.log('Starting game with ID:', gameId)
const response = await startGame(gameId)
console.log('Game start response:', response)
// Backend will broadcast game:started event to all players
// Navigate to game page
navigate("/game")
} catch (error) {
console.error('Failed to start game:', error)
alert(`Nem sikerült elindítani a játékot: ${error.response?.data?.error || error.message}`)
}
}
const copyGameCode = () => {
navigator.clipboard.writeText(gameCode)
alert('Játék kód vágólapra másolva: ' + gameCode)
}
const getInitials = (name) => {
return name
.split(" ")
@@ -57,31 +119,121 @@ const Lobby = () => {
style={{ background: "rgba(0,0,0,0.25)" }}
>
<h1 className="text-4xl md:text-5xl font-extrabold text-green-300 mb-4 text-center tracking-wide drop-shadow-lg">
{user} Lobby-ja
Játék Lobby
</h1>
<p className="text-lg text-zinc-300 mb-8 text-center">
Játékosok, akik csatlakoztak ehhez a szobához:
{/* Game Code Display */}
<div className="bg-gradient-to-r from-green-600/20 to-teal-600/20 rounded-xl p-6 mb-6 border-2 border-green-400/50">
<p className="text-lg text-zinc-300 mb-2 text-center font-semibold">
Játék Kód:
</p>
<div className="flex items-center justify-center gap-3">
<p className="text-5xl font-mono font-extrabold text-green-300 tracking-widest drop-shadow-lg">
{gameCode}
</p>
<button
onClick={copyGameCode}
className="bg-green-600 hover:bg-green-500 text-white px-4 py-2 rounded-lg font-semibold transition-all duration-200 hover:scale-105"
title="Másolás vágólapra"
>
📋 Másolás
</button>
</div>
<p className="text-sm text-zinc-400 mt-3 text-center">
Oszd meg ezt a kódot másokkal, hogy csatlakozhassanak a játékhoz!
</p>
</div>
{/* Connection Status */}
<div className="mb-4 text-center">
<span className={`inline-block px-4 py-2 rounded-full text-sm font-semibold ${
isConnected
? 'bg-green-600/20 text-green-300 border border-green-400'
: 'bg-red-600/20 text-red-300 border border-red-400'
}`}>
{isConnected ? '🟢 Kapcsolódva' : '🔴 Kapcsolat megszakadt'}
</span>
</div>
<p className="text-lg text-zinc-300 mb-6 text-center">
Játékosok ({currentPlayers.length}):
</p>
<div className="bg-zinc-800/90 rounded-xl shadow-lg p-6 mb-8">
<ul className="flex flex-col gap-4">
<li className="bg-zinc-700 py-3 px-4 rounded-xl text-green-400 font-semibold flex items-center gap-4 shadow hover:shadow-green-500/20 transition">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
style={{ background: "rgba(34,197,94,0.12)", color: "var(--color-mint)" }}
>
{getInitials(user)}
</div>
<span className="text-white text-lg">{user}</span>
</li>
{currentPlayers.length === 0 ? (
<li className="text-center text-zinc-400 py-4">
Várakozás játékosokra...
</li>
) : (
currentPlayers.map((player, index) => (
<li
key={player.id || index}
className="bg-zinc-700 py-3 px-4 rounded-xl text-green-400 font-semibold flex items-center gap-4 shadow hover:shadow-green-500/20 transition"
>
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
style={{ background: "rgba(34,197,94,0.12)", color: "var(--color-mint)" }}
>
{getInitials(player.name || `Player ${index + 1}`)}
</div>
<span className="text-white text-lg flex-1">
{player.name || `Player ${index + 1}`}
</span>
{player.isReady && (
<span className="bg-green-600 text-white text-xs px-2 py-1 rounded-full">
Kész
</span>
)}
{player.isOnline && (
<span className="text-green-400 text-xs">🟢</span>
)}
</li>
))
)}
</ul>
</div>
<div className="flex justify-center">
{/* Role indicator */}
<div className="mb-6 text-center">
{isGamemaster ? (
<div className="bg-yellow-600/20 text-yellow-300 px-4 py-3 rounded-lg border border-yellow-400/50">
<p className="font-semibold">👑 Te vagy a Gamemaster!</p>
<p className="text-sm mt-1">Te nem játszol, csak indítod és moderálod a játékot.</p>
</div>
) : (
<div className="bg-blue-600/20 text-blue-300 px-4 py-3 rounded-lg border border-blue-400/50">
<p className="font-semibold">🎮 Te vagy egy Játékos!</p>
<p className="text-sm mt-1">Várj, amíg a gamemaster elindítja a játékot.</p>
</div>
)}
</div>
<div className="flex justify-center gap-4">
{isGamemaster ? (
/* Gamemaster view - can start game */
<button
onClick={handleStartGame}
disabled={currentPlayers.length < 2}
className={`px-8 py-3 rounded-xl font-semibold shadow-lg transition-transform transform hover:scale-105 ${
currentPlayers.length >= 2
? 'bg-gradient-to-r from-green-700 to-green-500 hover:from-green-600 hover:to-green-400 text-white hover:shadow-green-400/30'
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
}`}
title={currentPlayers.length < 2 ? 'Minimum 2 játékos szükséges' : 'Játék indítása'}
>
Játék Indítása
</button>
) : (
/* Player view - cannot start game, just wait */
<div className="text-center text-zinc-400">
<p className="text-lg">Várakozás a gamemaster-re...</p>
<p className="text-sm mt-2">Csak a gamemaster indíthatja el a játékot</p>
</div>
)}
<button
onClick={handleExit}
className="bg-gradient-to-r from-green-700 to-green-500 hover:from-green-600 hover:to-green-400 text-white px-8 py-3 rounded-xl font-semibold shadow-lg hover:shadow-green-400/30 transition-transform transform hover:scale-105"
className="bg-gradient-to-r from-red-700 to-red-500 hover:from-red-600 hover:to-red-400 text-white px-8 py-3 rounded-xl font-semibold shadow-lg hover:shadow-red-400/30 transition-transform transform hover:scale-105"
>
Kilépés
</button>
@@ -1,4 +1,4 @@
import React, { useState } from "react"
import React, { useState, useEffect } from "react"
import { useNavigate, useLocation } from "react-router-dom"
import Navbar from "../../components/Navbar/Navbar.jsx"
import Background from "../../assets/backgrounds/Background.jsx"
@@ -6,6 +6,7 @@ import Footer from "../../components/Footer/Footer.jsx"
import useRequireAuth from "../../hooks/useRequireAuth.jsx"
import ButtonGreen from "../../components/Buttons/ButtonGreen.jsx"
import { motion } from "framer-motion"
import { createGame, joinGame } from "../../api/gameApi.js"
const GameLobbySetup = () => {
const [username] = useRequireAuth({ key: "username", redirectTo: "/login" })
@@ -16,19 +17,82 @@ const GameLobbySetup = () => {
const [maxPlayers, setMaxPlayers] = useState(4)
const [isPublic, setIsPublic] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [createdGameCode, setCreatedGameCode] = useState('')
const [showSuccess, setShowSuccess] = useState(false)
const handleCreateLobby = () => {
console.log({
deckIds,
maxPlayers,
isPublic,
})
// Itt küldd el az API-nak a lobby létrehozását
// navigate("/game-lobby", { state: { lobbyId: response.lobbyId } })
const handleCreateLobby = async () => {
setLoading(true)
setError(null)
try {
const username = localStorage.getItem('username')
console.log('Creating game - username:', username)
if (!username) {
setError('Kérlek jelentkezz be először!')
setLoading(false)
return
}
// Backend expects deckids (array), maxplayers (number), logintype (0=PUBLIC, 1=PRIVATE)
const gameData = {
deckids: deckIds, // Array of deck UUIDs
maxplayers: maxPlayers, // Number
logintype: isPublic ? 0 : 1, // 0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION
}
console.log('Creating game with data:', gameData)
const response = await createGame(gameData)
console.log('Game created:', response)
// Verify localStorage still has username
console.log('After create - username:', localStorage.getItem('username'))
// Backend returns game object directly
const code = response.gamecode || response.gameCode
if (code) {
setCreatedGameCode(code)
setShowSuccess(true)
}
// Creator needs to join their own game to get a gameToken
// This allows the WebSocket to recognize them as the gamemaster
try {
const username = localStorage.getItem('username')
const joinResponse = await joinGame({
gameCode: code,
playerName: username
})
if (joinResponse.gameToken) {
localStorage.setItem('gameToken', joinResponse.gameToken)
console.log('Creator joined game as gamemaster, token stored')
}
} catch (joinError) {
console.error('Failed to join game as creator:', joinError)
// Continue anyway - the creator can still try to join manually
}
// Wait 3 seconds to show code, then navigate to lobby
setTimeout(() => {
console.log('Navigating to lobby with code:', code)
navigate('/lobby', { state: { gameCode: code } })
}, 3000)
} catch (err) {
console.error('Create game error:', err)
console.error('Error response:', err.response?.data)
console.error('Error status:', err.response?.status)
setError(err.response?.data?.message || err.response?.data?.error || 'Nem sikerült létrehozni a játékot')
} finally {
setLoading(false)
}
}
if (deckIds.length === 0) {
navigate("/choose-deck")
navigate("/choosedeck")
return null
}
@@ -67,6 +131,27 @@ const GameLobbySetup = () => {
{deckIds.length} pakli kiválasztva. Add meg a játék részleteit.
</motion.p>
{error && (
<div className="bg-red-500/20 border border-red-500 rounded-lg p-4 mb-6">
{error}
</div>
)}
{createdGameCode && (
<div className="bg-green-500/20 border border-green-500 rounded-lg p-6 mb-6">
<p className="font-bold text-xl mb-2">Játék Létrehozva! 🎉</p>
<p className="text-3xl font-mono tracking-wider text-green-400 mb-2">
{createdGameCode}
</p>
<p className="text-sm text-gray-300">
Oszd meg ezt a kódot más játékosokkal, hogy csatlakozhassanak!
</p>
<p className="text-sm text-gray-400 mt-2">
Átirányítás a lobby-hoz 3 másodperc múlva...
</p>
</div>
)}
<div className="bg-[color:var(--color-surface)]/80 backdrop-blur-lg rounded-2xl p-8 shadow-lg space-y-6">
{/* Max Players */}
<div>
@@ -115,11 +200,17 @@ const GameLobbySetup = () => {
<div className="flex justify-center gap-4 mt-8">
<ButtonGreen
text="Vissza"
onClick={() => navigate("/choose-deck")}
onClick={() => navigate("/choosedeck")}
width="w-auto px-8"
className="bg-gray-600 hover:bg-gray-700"
disabled={loading}
/>
<ButtonGreen
text={loading ? "Létrehozás..." : "Lobby Létrehozása"}
onClick={handleCreateLobby}
width="w-auto px-8"
disabled={loading}
/>
<ButtonGreen text="Lobby Létrehozása" onClick={handleCreateLobby} width="w-auto px-8" />
</div>
</motion.section>
</main>
@@ -1,24 +1,75 @@
// src/pages/Home/Home.jsx
// Régi PlayMenu-s oldal, "Home" néven
import { useEffect } from "react"
import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import useRequireAuth from "../../hooks/useRequireAuth"
import Navbar from "../../components/Navbar/Navbar"
import Footer from "../../components/Footer/Footer.jsx"
import Background from "../../assets/backgrounds/Background.jsx"
import PlayMenu from "../../components/Landingpage/PlayMenu.jsx"
import { joinGame } from "../../api/gameApi.js"
export default function Home() {
const navigate = useNavigate()
// a hook inicializálja a user-t a localStorage-ból és visszaadja a state-et + settert
const [user, setUser] = useRequireAuth({ redirect: false }) // no redirect on unauthenticated visitors
const [isJoining, setIsJoining] = useState(false)
// Dummy callbackok és user példa
const handleJoinGame = (code) => {
alert(`Csatlakozás játékhoz: ${code}`)
// Join game handler - csatlakozás játékhoz kóddal
const handleJoinGame = async (code) => {
if (!user) {
alert('Kérlek először jelentkezz be!')
navigate('/login')
return
}
console.log('=== JOIN GAME DEBUG ===')
console.log('Current user:', user)
console.log('Game code:', code)
console.log('LocalStorage username:', localStorage.getItem('username'))
console.log('LocalStorage authLevel:', localStorage.getItem('authLevel'))
console.log('======================')
setIsJoining(true)
try {
const joinData = {
gameCode: code.toUpperCase(),
playerName: user || 'Player',
}
console.log('Sending join request with:', joinData)
const response = await joinGame(joinData)
console.log('Joined game:', response)
// Backend returns game object directly
if (response.gameToken) {
localStorage.setItem('gameToken', response.gameToken)
}
navigate('/lobby', { state: { gameCode: code.toUpperCase() } })
} catch (err) {
const errorMsg = err.response?.data?.error || err.response?.data?.message || 'Nem sikerült csatlakozni a játékhoz'
alert(errorMsg)
console.error('Join game error:', err)
console.error('Error details:', err.response?.data)
} finally {
setIsJoining(false)
}
}
// Create game handler - új játék létrehozása
const handleCreateGame = () => {
alert("Új játék létrehozása")
if (!user) {
alert('Kérlek először jelentkezz be!')
navigate('/login')
return
}
// Navigate to choose deck page to start game creation flow
navigate('/choosedeck')
}
const userObj = { name: user }
// ha szükséges a user módosítása máshol: setUser("újnév") automatikusan menti localStorage-be
+11
View File
@@ -13,6 +13,17 @@ export default defineConfig({
},
hmr: {
clientPort: 5173,
},
proxy: {
'/api': {
target: 'http://backend:3000',
changeOrigin: true,
},
'/socket.io': {
target: 'http://backend:3000',
changeOrigin: true,
ws: true,
}
}
},
preview: {