From 5a4be5b7d3223cc5298cdd7f1e144d1024e842dc Mon Sep 17 00:00:00 2001 From: magdo Date: Wed, 5 Nov 2025 20:20:22 +0100 Subject: [PATCH 1/4] game workflow corrected --- .../Game/commands/StartGameCommandHandler.ts | 2 - .../commands/StartGamePlayCommandHandler.ts | 6 - .../Services/GameWebSocketService.ts | 9 +- .../src/Domain/Game/GameAggregate.ts | 28 +- .../src/Domain/IRepository/IGameRepository.ts | 22 +- .../Migrations/1757939815984-full.ts | 28 -- .../Migrations/1758463929834-full.ts | 30 -- .../Migrations/1762370334693-full.ts | 16 + .../Migrationsettings/1758463928499-full.ts | 10 - ...39815062-full.ts => 1762370333970-full.ts} | 3 +- .../Repository/GameRepository.ts | 13 +- SerpentRace_Docker/sql_schema_only.sql | 324 ++++++++---------- 12 files changed, 193 insertions(+), 298 deletions(-) delete mode 100644 SerpentRace_Backend/src/Infrastructure/Migrations/1757939815984-full.ts delete mode 100644 SerpentRace_Backend/src/Infrastructure/Migrations/1758463929834-full.ts create mode 100644 SerpentRace_Backend/src/Infrastructure/Migrations/1762370334693-full.ts delete mode 100644 SerpentRace_Backend/src/Infrastructure/Migrationsettings/1758463928499-full.ts rename SerpentRace_Backend/src/Infrastructure/Migrationsettings/{1757939815062-full.ts => 1762370333970-full.ts} (76%) diff --git a/SerpentRace_Backend/src/Application/Game/commands/StartGameCommandHandler.ts b/SerpentRace_Backend/src/Application/Game/commands/StartGameCommandHandler.ts index 73918f1e..3ab27f71 100644 --- a/SerpentRace_Backend/src/Application/Game/commands/StartGameCommandHandler.ts +++ b/SerpentRace_Backend/src/Application/Game/commands/StartGameCommandHandler.ts @@ -68,8 +68,6 @@ export class StartGameCommandHandler { orgid: command.orgid || null, gamedecks, players: [], - started: false, - finished: false, winner: null, state: GameState.WAITING, startdate: null, diff --git a/SerpentRace_Backend/src/Application/Game/commands/StartGamePlayCommandHandler.ts b/SerpentRace_Backend/src/Application/Game/commands/StartGamePlayCommandHandler.ts index f19b9362..153dfabb 100644 --- a/SerpentRace_Backend/src/Application/Game/commands/StartGamePlayCommandHandler.ts +++ b/SerpentRace_Backend/src/Application/Game/commands/StartGamePlayCommandHandler.ts @@ -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'); diff --git a/SerpentRace_Backend/src/Application/Services/GameWebSocketService.ts b/SerpentRace_Backend/src/Application/Services/GameWebSocketService.ts index 710e25c8..1a44e9c2 100644 --- a/SerpentRace_Backend/src/Application/Services/GameWebSocketService.ts +++ b/SerpentRace_Backend/src/Application/Services/GameWebSocketService.ts @@ -1222,14 +1222,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,7 +2235,7 @@ export class GameWebSocketService { const game = await this.gameRepository.findByGameCode(gameCode); if (game) { await this.gameRepository.update(game.id, { - finished: true, + state: GameState.FINISHED, winner: winnerId, enddate: new Date() }); diff --git a/SerpentRace_Backend/src/Domain/Game/GameAggregate.ts b/SerpentRace_Backend/src/Domain/Game/GameAggregate.ts index ece7aae6..46ddf85e 100644 --- a/SerpentRace_Backend/src/Domain/Game/GameAggregate.ts +++ b/SerpentRace_Backend/src/Domain/Game/GameAggregate.ts @@ -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 diff --git a/SerpentRace_Backend/src/Domain/IRepository/IGameRepository.ts b/SerpentRace_Backend/src/Domain/IRepository/IGameRepository.ts index dd79dba5..73d9b958 100644 --- a/SerpentRace_Backend/src/Domain/IRepository/IGameRepository.ts +++ b/SerpentRace_Backend/src/Domain/IRepository/IGameRepository.ts @@ -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 { // Game-specific methods findByGameCode(gamecode: string): Promise; -======= - -export interface IGameRepository { - create(game: Partial): Promise; - findByPage(from: number, to: number): Promise<{ games: GameAggregate[], totalCount: number }>; - findByPageIncludingDeleted(from: number, to: number): Promise<{ games: GameAggregate[], totalCount: number }>; - findById(id: string): Promise; - findByIdIncludingDeleted(id: string): Promise; - findByGameCode(gamecode: string): Promise; - 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): Promise; - delete(id: string): Promise; - softDelete(id: string): Promise; - - // Game-specific methods ->>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2 findActiveGames(): Promise; findGamesByPlayer(playerId: string): Promise; findWaitingGames(): Promise; findFinishedGames(from?: number, to?: number): Promise<{ games: GameAggregate[], totalCount: number }>; addPlayerToGame(gameId: string, playerId: string): Promise; removePlayerFromGame(gameId: string, playerId: string): Promise; - updateGameState(gameId: string, started: boolean, finished?: boolean, winner?: string): Promise; + updateGameState(gameId: string, state: GameState, winner?: string): Promise; } \ No newline at end of file diff --git a/SerpentRace_Backend/src/Infrastructure/Migrations/1757939815984-full.ts b/SerpentRace_Backend/src/Infrastructure/Migrations/1757939815984-full.ts deleted file mode 100644 index 91eabf61..00000000 --- a/SerpentRace_Backend/src/Infrastructure/Migrations/1757939815984-full.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class Full1757939815984 implements MigrationInterface { - name = 'Full1757939815984' - - public async up(queryRunner: QueryRunner): Promise { - 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 { - 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"`); - } - -} diff --git a/SerpentRace_Backend/src/Infrastructure/Migrations/1758463929834-full.ts b/SerpentRace_Backend/src/Infrastructure/Migrations/1758463929834-full.ts deleted file mode 100644 index 9257f486..00000000 --- a/SerpentRace_Backend/src/Infrastructure/Migrations/1758463929834-full.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class Full1758463929834 implements MigrationInterface { - name = 'Full1758463929834' - - public async up(queryRunner: QueryRunner): Promise { - 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 { - 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)`); - } - -} diff --git a/SerpentRace_Backend/src/Infrastructure/Migrations/1762370334693-full.ts b/SerpentRace_Backend/src/Infrastructure/Migrations/1762370334693-full.ts new file mode 100644 index 00000000..610a6821 --- /dev/null +++ b/SerpentRace_Backend/src/Infrastructure/Migrations/1762370334693-full.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Full1762370334693 implements MigrationInterface { + name = 'Full1762370334693' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`ALTER TABLE "Games" DROP CONSTRAINT "FK_330362bff8b25bb573f31fb4023"`); + await queryRunner.query(`ALTER TABLE "Games" RENAME COLUMN "winnerId" TO "winnerid"`); + } + +} diff --git a/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1758463928499-full.ts b/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1758463928499-full.ts deleted file mode 100644 index e2e2ebab..00000000 --- a/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1758463928499-full.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; -export class Full1758463928499 implements MigrationInterface { - - public async up(queryRunner: QueryRunner): Promise { - } - - public async down(queryRunner: QueryRunner): Promise { - } - -} diff --git a/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1757939815062-full.ts b/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1762370333970-full.ts similarity index 76% rename from SerpentRace_Backend/src/Infrastructure/Migrationsettings/1757939815062-full.ts rename to SerpentRace_Backend/src/Infrastructure/Migrationsettings/1762370333970-full.ts index ee04745b..aeb7547a 100644 --- a/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1757939815062-full.ts +++ b/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1762370333970-full.ts @@ -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 { } diff --git a/SerpentRace_Backend/src/Infrastructure/Repository/GameRepository.ts b/SerpentRace_Backend/src/Infrastructure/Repository/GameRepository.ts index b4832ee2..e8a79e23 100644 --- a/SerpentRace_Backend/src/Infrastructure/Repository/GameRepository.ts +++ b/SerpentRace_Backend/src/Infrastructure/Repository/GameRepository.ts @@ -385,19 +385,16 @@ export class GameRepository implements IGameRepository { } } - async updateGameState(gameId: string, started: boolean, finished?: boolean, winner?: string): Promise { + async updateGameState(gameId: string, state: GameState, winner?: string): Promise { const startTime = performance.now(); try { - const updateData: Partial = { started }; + const updateData: Partial = { 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; @@ -407,7 +404,7 @@ export class GameRepository implements IGameRepository { 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(); diff --git a/SerpentRace_Docker/sql_schema_only.sql b/SerpentRace_Docker/sql_schema_only.sql index 4cc18c87..a24d209b 100644 --- a/SerpentRace_Docker/sql_schema_only.sql +++ b/SerpentRace_Docker/sql_schema_only.sql @@ -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; \ No newline at end of file +END; \ No newline at end of file From 5b177c77fc428d99fd3a15e9efe44615bcb58cd1 Mon Sep 17 00:00:00 2001 From: magdo Date: Thu, 6 Nov 2025 19:37:32 +0100 Subject: [PATCH 2/4] game workflow corrected --- Documentations/COMPLETE_GAME_WORKFLOW.md | 32 ++++++++++++++++++ Documentations/COMPLETE_GAME_WORKFLOW.pdf | Bin 229346 -> 237098 bytes .../Services/GameWebSocketService.ts | 32 +++++++++++++++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/Documentations/COMPLETE_GAME_WORKFLOW.md b/Documentations/COMPLETE_GAME_WORKFLOW.md index 12beb10b..eac61973 100644 --- a/Documentations/COMPLETE_GAME_WORKFLOW.md +++ b/Documentations/COMPLETE_GAME_WORKFLOW.md @@ -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 diff --git a/Documentations/COMPLETE_GAME_WORKFLOW.pdf b/Documentations/COMPLETE_GAME_WORKFLOW.pdf index 7a10fb5876d3e8edfb0ca126a2bf4522a1b6cdcc..08e14ee57c1c4320f4a5f50cf7bb7f6aaee72f1e 100644 GIT binary patch delta 47434 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 delta 39619 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

st8fL& zf^w_^I0#<}kA&~wt4Vmo)ZXW?8TiGx8LG6zBL7^O5I@J72>6PNQ!pcW2OKvr;)Jgz zbN+{L`ziWGAeR!hUtEZJIVvhJs)d7bEqpb}_CJK%Pcb$EiC;0?0(lKHP}~q;jEz`L z#{SRXhFBifU$Q=en95txw?0EOsIQLzBYDJXlKg)Nx1X9Q5O_3+zU{V*+z4i;2(Wo! znLt--a{LeB_H*+B27z7CH$Inr>p*b>n->uOsd?d-Ceh`ge74PPf9i&=_~HfSUYekj zC<1IUK&<9uuo7)Ke$K=H6E}3ZOMgj7aFRSr%+ABnVCsPcqjBWFPvgI&6U+Vo9v+S_ z4{X19sKh4@D+W|^glR1(LSTfA{7(_~uYpBE(tl<^C?pA3WPz#RV1cnT@;~Lh{~A~% z%q;k0w!xxF$fh-Pf*e$_z?d2Nzr)PSL-`qI#*j>7Q;|`i%!~wMX5?xz^Dj^Tzou9u zuKAx?Aq>BgJ1@8yrwj@f3XIfI|30acJbV5nu$G7N6Pg7>kT~SXghdK4Gem*$J8Cug zePzxEeqqjE?$Y;@+IMr>%JT+d=1C(opSl`Gqu_No@TJ^7)flf-dr%C(X#4Yeht)~H z{WI(OH7z0GY7(mc&I`aINfMN8X?37(^ztgK&**>6%`LA{`**{SUqc8ykMbQtIMS)V zCI_}2RENMAdU;jM|7CBk9$`;9nq`A3Re3&W!%>Nb^u} zFQ^BN2BTv1zwbf+645O8|NFV?JA{x}k{AOhw*p59%y0g*&~25T5&knBBJpm%uB`jT zCj@BH>;?({d_zBQ9BC$q`Yt~xL9eWKCV8^*Wr^adK30T7e#OyQ?$Y(>w>$#IaF(RT#jG(R{28^LG|2{+i2L2(g)%uxbQ(xDf5tp77 zbalysx0EtC@ zL-IJxADqn0-T6*kFq-ZU%*h+|<5bkrkIEk{8E_i%Gj7gt)MXkywa7hZHSeHWcu+Hj zXR0|ea)41$-11FZg}Z4@@vL3enbem0k;t)Gc^eICgN?zqWhc3y05zZo6Q);@q=}~$ z&tE6svn9wmX*y=nVV<^A;VRDOL$GlIC8EuWQL|?5QcU zKEkWq;TwoKMxI@j9W3Ng%9mM))2@7~Lc7-?uu%0{%|N1cPCyV(l)7NHqX}bk_U5V%_(LShTLKd(mFEOMyRDC`+eUbK;ct2{#VsIohh6 z=%Z=}P9Y{U2N?VdN>lN!JAl2SjQa&0PkJazWUSw{p(=djX@#Oqx=7>`=ac@vTu&78 z&%8N5b)x#lJia*oGK2C4(^i?W5Jq@`e`e60V*5&}p@#6TNU^YzP_)pj3rI#5X&< z++Z73;^AlUifS6CPv=7R$z7#`*|FDM*vn;UvDFG+@}{eHOlq`w$TORH_u(CqwhiW#r|ZtizHd4Uu?RT%oW`Dz@@uq!56yqSh91T?_Gfw4VqHG|~u6P3`FYQ(=y;D6-r<&J)DeBbal2KUJer(7zE zE`G{LmIoTN;J^qR_kWzgm;3)cLf;ZN0*+mwr{XvkGXD)=DHR+TgX8}FaQK%Pe7XPM zL-cLlA5Y>06if4v^?}ZDI4}ms{ih<|)gmHdBJyVv25>ZPC3{z_C`cv>PDEe~j{Bcv zaNN%^I0AuL$sQEvi;{JM+9V!~!SVlB7<{>--y7dI42~ccot7Q$Jy7$*Wz%VQ3Km?*# z(TGgs2Pl9MFC4a-%=<+fRuP4z?)^0iq^|K23ZAB9XMTVJ7>g2XB8g&FYstfJK;fqz z`@rGIl^oa-3XE1{_CG)Yj2_{z-@%Wos0%+uka&_Pi3mbj@o_t{CeS&8SP;3QTwhlJ z!C|Y(k6#|H0{?VVpkJC&K8jzcAzn4e< zNj(O!uzlsu{&?7M#2=k34z3|a8PmGVKQyd>Rvc#c`y98)c5{9lt}#DRZ3c+X+MqY8#4Od%HVi3 z33fYAkfnpJvx9?{_!7B)6T3;lHFxKgaqgl32W0G?T0rG#5eK zdl$@cTGeXa{}Of}{Qo_LA>bs-W{Z)R$@D;Tro^+ru=_1_!T$mJ|3SU*HwkArie!I> z;=6@pf#73A?NaEJ>B(WhdNk&ftYH*bMM3wK*M1c`)`)O{5% za#D!x0>5!2e(R4LN8wPgubaQaVJOVXq^kKzG$J;Lc}UuZ@A`Ps+kRp2 zxg?_BpU=jl`#o&<%H>+_4FB|qrY4z$vfk-a7)VCbYct;`(6Z@nv)&J5WxXHDON(UI z+@|q87OZUuI7GGBCJ7y)KZ?6mv^i8tJ?|FdE4?*FW?irw`HQdiA7TJ*yEDvwV8@P^ z_B{hiA5uNLwdJU9ah8N2dtTXgmfJ1IrH6gApY4-;Z#>||W7gWTv$XomGbiPbu(wT( z6ejRB@?>u#dB$HwjhS*y2O?I9GX1(1P8X!+tLNZE-iZILSTUbdkHO zLuw{g(}(I_h2lt@RaGq`GbSEJ7B7Bz3xd^!*9PU z9eCNmbsnPGEgasr*xosT06 z)HZp~UqZR5B`Liw3?xD}rX&`2Q{Hk-#@<`;oq`w&)fK(Ho{J8QxXVC@l9h8Kj^ zI?j;Ipckm8D(&}dK2Vr)40G$UAgXneOv@!jHb1YoDAK znwRFNiUU+kkBRJOweq&@NbtNcS$%LwzEk9(7L+4k-X&M+D%EsL6Z5g8;DOp9bG|1Z z^0<2g#cr$@%u0#>$YglNTmpFgy!`gZHFZzAVissR(zaSYr8FG!&AQa*=sgVNKWU>0 zkK^VWkH!tUZ+v30ao=XcYBt-zf<0-@wZ;9NZ$@W4{Ukn)M}C-*@?q(UlZ^7C?AV{& zL>2jL+F{aNf&H%Y0L9GB(}ySJY;(JMJRir4V?L@4kG+J% zUs=SUsm&bbTZ3crnC@In7p~z37KhJXjxAL5JJNZnDy*F1rO<3ReTtU$4T}Y%!0ii0 zoQ#CKPeNQ%Qq>HEAF`#vqWvy$1!|9v$aHPd89d(}5uv{J5T%*zHYl|l?a5At!O|MR zwTV=)ip-;-@kzP0hV`ksg60s+`Loz;-4l~~fdfK!JOqOL`WW0ha^}=qA;6C5*=kp) zfqq2OlXsW*>ZGu;^gGh~ik{jrwB^Oq4wWh^hj&;{cAYi5)=*_o2?%bNVF{qJFjZo$ zJXamc$iaFo-)|F}(^Y37plsnOPAv99uo{28a&y@ z1&@#{4CkG~HfvPKh)ugI+a*M0S5SEvg-3TDj`4qf+h$L`x*8^)(7RA{yS6&nH2T_` znbCAt*6PNGb3!$O6Z?nWT;(>Dx9KZqc2~b>T@>!ozx|w}=8*O-{VC6>9H5MaU1Pce z>ZI3THsj*u0=Nh!oZX(uF`B_Meb+X=Pa1wCJ0Vm2zHapq=IX;5OhXE5zc=4=+bYK9(1>A=}{N-_E?z><{&TAR2YHchQDq`4a} zuA3fP=_4L)pRU2lDeckQtltc<^j^t*7kY45$LumrAYmNes$pu-{AddmmTHG@;DN9m zQvLKzknTs~e1crpPZ}oCx3U#po-Dq+yCij6c>3rt*`T#2mxkU{!w6NR3;8Q+zzxOa zJQdpb##Ry&-PQSu#qpzel|#hlpbTD#S;`k$Tnw>;&71CS8RZE*7-Ls~28s)X@CUFD zNA0%wS_~8NyT&iE_)SGz*BD9B%WB%{DLEx2Wpl98F03&OdkTVS86l86ua(Vz++rCL zy^pif?crUY(>#*gBM)!NXi@j-aEzIp*-qzadC`2RtQaGpxJ{Z{NYhI2D zFBFHEY9RUoeLadf)`;ALCK>?Rw^Z(L2@nOjN@&F+6UDgcyptV%ULA+dTo_AdloZnucf&Y zcw>iz=xDTl+;Cv4aQcDV^qhS_c1n&#$6)Qg zbj_WB(P(&tOL#Lg=SkjH#fTF#40q0R#Od-76z=vO`&H>*;AWosk+6N zv$Z#CvkVRtFA5(yV@>N*E;buDb#)4*vIZ|8u#sg)zx@H%?wdEA)(^Bk<{5PzekabC zRXp+Wra)%g!safw;|%xg-Hfix>j2eGu6Na&F0phZiMQOYR|XE^U)HfWj-9$Z>p03@ zH$P?(`q=TYv1nV;Yrc`2IANi)F^-r{(Vb_d#Xt1F;@sw8BraXwQRF*G%X&}wp4{kR zR#lNT2VaVJc|<6Bc!YL^zI&ZfEhH0ZL6aMMX|ieWUcaq~qx$?sE%Q(2<@ajq7B$wTI#>#0-z@-@W)I zyYZCa>*6!R4y8(+9&@SA8Y6K%iE?h`PRSY*w$z2izRFoK#XZWyV?`Y(C$F5^OGz(b z?KGQr?R0tf@E!Y3?2C3ha3t3xYT*!2Q=;;uT={}>BumGg;o(Q6nHKr?tQvjJEi7a{ zliFDU#0^|7JTv$5<&9GRGh@cGZzbli?k14it`kAm3eH%Gn)!1(J7qrExVvz7VT4TT z#rviQ@^X{4)YR0~7Ti31#U%K9+s%(4x$fD?w(=$(B4BxI-vKP!j*eha!7=7=d_CsFb?p@Bld}=5iJmduV{Y|?LruCP`f)8f zvsbg8@I7h2cVY?|@zT5ORae$PqCr+O7Y6TedNSnz3)|?;8eof*Q?-}J&A=muhu)`X z;#@`Xey0z}empgtGBCK|$}6F_$ZZE;tpO)`C5Io+0vY$`7W&+)#Dn9 zTjPg)Qsyb9Tf8n@d3j{2N#9|3&g(|XE{`&b+aCw782eau#ZHEfNIo~0 zRrYRn(4>R&WzTGeg~r(n(T!De7lBM=ha1k+g!ZbNUebpj#jD)22{tg^wAntZPrMu8 zoS^p+m3eyZQ5;WLS`1a!)%jP8+)o{Hr}oYWIII)BViB-?sD=?2RmiOcdAudMRyeay z=YUf3ql=m6n#Bd7`>!Y8t7moG?=F>5;F3%uqK7($pKu8oyiE>0Z!0Z-!y2C)%}Oa|orKT6XeE z4M8-E#Ov{#i)^e%b02PocReN>I-bPO?p3(C==S`Ef?FQ2um_?yu<+P>3uS)(Ks*4f z>)Wu%zX)59I-yN5NpBx@i@_&t-HUOCRD zykl#(N@oW*%%1Dr`@FwZ9sqxe80k%L} zLVSDN+vcR_mJO4TPR{rju$E?{qz2_%7ZZ%6-3`RId(_YwpPSOYu)~Ca4i0g?t{f0{ z=Ab6aTjtBA_Qg3OS%x=p0j5v1u=(3Pc*@=HI;j!Va!X(dj+cW6)D4oG+8eSF*HeNY zI6aB8HxYYb65f}WJ9^pc+@X#W*K#j!1A^O|JQ@?5)Cp0hAL z%K^}a9i!O%hV@UW<}WsdnmpXy?O?V}cTb~QW^w-H_3*~?%}ryO!@+xl z#~-B=BY5$V0wF$Ig>ev-rpo9S4@ryM8fWDl-#1AE)J$xnNg?bhl&qduPg7AjaeyYp zYKA#T3%0;p4Tbq7_F2;Gd@$igv$GzU;IGy~URFB2e?})rd8_mtm&85^RxWezoz=yN z(%`;s8ZSiG{Ry=oQ6@vq$BC7OahFh8^uZ|ZppJrT6wb-_Jy;exEBI~^46nG^(H}9E~rR`e;>Tqm^tj+t}s?MEL)EC$c^eWY0=ZSb28$0?!TYK zx&>`hTQHj4;s>*KeWV&6SsQrs*bCZrAig)_V@do2x(8Z)kw!U3uuj+3p57^M0I?CE zbzwcGp6v5F<`9DkKdSt?&fzWjS5HS8>gQ;AksTJJeQ{dlm{)Iq=0jiRxML4}$wNBJ zde(fr$bSDrcrW*c51yun_M5P8D=fRKnr?A_69*56XZCGXy_eySZYmc(%F3R7oQD^F z2gFN2C&UrBLkqdHj~i5bj04@s(+u<3xVc`O&zN?76iGK$W*lmel{`~-j(z51l4Pd* zVDHO;b(+1o(PO10b?R#`h-sNV3lejE(B1kX=S@jyJ-IiItG7}0r5RRDxemd=QB8P5 z8F467dpEj>-6Z<{8+ub8s>lwg>JWdBLN!q5-ZM9A;&zWdYV>-uTP)ih&2;Ln6_t~-6xGqd)@a2UA4lHzS1Hrh_F*@$ z#kQuM--Llo?B@%8GcC}~n(_`ab0YezOZl;9q4;Kcsis~9nx_cCJL+_M=|0l;$sc#y zAl}N_97DgUx$Q{$M&{IvQ@ZL#IfeVK>8m`s2^>+6?iH^+KwoCy9i-py|JYmDpd}%u zVe_a!z>BQ=_zeukI@z{Yhg_m->3wri!*DgxHq{o z5PPiPR@vBgth0wt(r!&tNMJkH;Uh)$gH4{ciS4_|b2gk_U+*tanf|ORl{Jx($v-(& zWM~7hH^4~NBxnC%9wgD%^x+G-7`g&L`fEG%4wjYx)pm)EL z(r8!a`#w{qHa(G=Hh8%vI$^7ot#{{{^jkO?RsXHSBI|3C)%M%YFxcPC*ip-CX0ewy zbW3TWqhhFqt-Nh_>cD(7K4=#w!g?`;(j|P9DuS0YotB-KR_q#$6@$W_quK#Sq0{FX z(#V0zuvFP+^eRK!@xog5FJC+u&>ARsTl;qFEX6GCEPD3*LWrN-5uGEl(qh~(DQ8k* zQsTC~$a+ybaC{+Kay$2_{3Lp8gTDY9`wC_SG=D(h-rVvt0fTwE`@bLc(O+bE!@{ zWllf2l6v~iUg??fl2h{3QYsBFiR#z))u+4g?6T!CZBk0Cxya+8W~zKNIHPU%sly(> zsd<|R*f1Zra;p%ff7ujnilVLCyyc7vJ4bRD_k?U67NPP)!)4~yyM}r6aqh%sMInVw z|H|k9Q+@B{2lTv-(K%2BV+vfnfB4L) z^BOVh6td$G=3)867&`9sAhzsq6#Za@S(xVQsLGzHtHJzHzA>fGV!piKrnCf2Uar+dFM(a(6yTZx@z5^SDw`+{lSWf1juw{^s zea6Z4Eo?Be7L6uRpaP4j-6O5;1i&$#&E)0@aTrY_VaS@jPB3vcwhTr zt#%4I&6+f3dKJ)H_2(rC#9IXr-+7EdqrSW!5rIUYh<6M7qubRJX4L80Sx5gOYnSQ1 zses&@6#X3?+jA9s%Lw;E&fiH;$Pqoup)Pc64Tb-y_w2LJ_@&mUcm}ZQb2)4}DVs9Z za^@A+Nd;Jw0`IxziL_@a0RRxMD$>^idqLy3*V>yrg{pv8Qp?CfqXbJkuSZu|9i)G| zDsXKnrzxZG34PujGUZMz6X|^ul7UczKr??^vYlCWbYuu!x?$A9Yg`ORE4?PH-O2rw zsz8-oC9myE8Dq!3-7~zRoAdVWxiMi-WyPTHA{?W6JkT=4?P(oePi`IX#$VV@MsnzU ztbh1EcqU;+5lK*ZN~5rc7T1M{J24?-zWs%HbQSLq`R)5gYnT%4xG>eyoY`@52+_iT zK#RROSCx^uyJzmg9QluuqdU~1D2iSwbnX{x$=Q@AaUtp0+Ux^2*4|6y7-Bv`R;#)( zp-&^@HlSV2@JQJ+w)ps=%F|*q=JMgTB&JbPsR!$bQ}Q zjzEk3LPIoG0_)sAzRHRzus6m`EDXq-I>%!W_!8;a#58Cz^Z@Xd(8&ZGdWGG&=;y7T zoH(yB87brObb7w`^^eyzP(vcUOHn6HV`;$kozRiS#thtuH^jQ z$C16E!{?F=Lr1ASZ8S{docB)9@1od3Hki5Cd|gLDn_uo`V>xs3?%sBz-IxjQ$#v~5 z349RNP3Z#sWQf}RZm(G%?gmCvI$EVtldrUu?;G6NQ&1K+nKjYW=vjDwn8m7X%{~F& zl89$0xtRxmjZJ~Y=?4p!6Vb@2Ea1V@*Xzn37i3KVn1td>6Yc?TXq3HA7fWt}_=M8j z<$Gi-nO+GuRlYWIFiznHoBcXo{9S%8Gau@Al>M8O^$eg!Cyb|r>B=Jki04?s4(JJ` zSJ_Hx+$j6*t1UtU96IE_bJj-=NmNKCEFScjdo|bcLb=G0l5dzxhrvvqS;{5K^^WXz z*m~8(0H)fx(kqwZKg7qWt91Fkb_=;Q%H!1vsexs+O(qz7`n*5s;h*=x+9Zx$D_ynE z)?ZvB{T?TZ7EP-2Mb)p6Dk1^F)gjZLddQKi=zbiHYLS1dPJE4>|BGR~` zeU0JkDk#@Agc;o<@5|4G*y8R`85*_B-13`OFM6ZysUuaGzrkE*N73D@*;G0CmY46o z%VcO#R(mPSny^?WcROZ{VVQffQoXx9Je11f;ChMs79r{*MPluaNupc;MF19>P?LAn zk+Rz}t$DIm7^qD<*rM;@Ii8O*j||_cGL-Wiw@qzggj_OOG^L7H=QJU!6}0naG<(etf0O)StN$(9@FJ#48#oMYgV2Pnj^chvs2ldD(=uUy<7t=Cj;*kl*W>UZR@|CMauW3uQ%P?^O` zqZrxY$DN7=xm3#fR0mJo=X~9x&@gAXIC^2P6XgdFjH^h znE0sTqMW2McOg1)f&=ThX_}+SeC)ZDGOK+}!+`_|2R1T;!H2DZeSs5kQN!A&qt5Vn zndP@QPOdS2yH*^fvY3A8vM!(+5w_8-WrKxH^~HwGLw-&FqejZ}}8rI~vze*WzMFp|}^(^6Ss zgdP2S%ifFEwqKk-bNlg?>mIx+&$pfB#Shje^CoV8%Q;Ugciuy0huW^}y}tK6G{K zy=LYNs5`Fc)9yk!=4*t0GCo~n2XtgRE>sk8@se+5&>626uC%7Bmo?jg9=6QM(Dg#L z$l6QHN6SJDE_-H-&a|Eh(>OdRC2?nTsIHxP-P!7jveZF2`qETK^H>$@s=ME&Sxuo!;well8Oe8*< z%b&V&M}FMQTK$Ied`Q*nD=PB?&TGx& zZ{8mnTaYs}lTuStqSB|=gL_H=UF`Cb$$5T-jy29HE!jJF=`W1QE!eh75BtxD4%v=p zuAh{=!qQjMZ(MZjdiF50yQxG<{j?O>Yi|>WtFj1 zl5QR4zJ9v8!t331<#D!#is-Wd_L$NwN+IshGZg9-GWI)zuhO!_uUQa?YD z;|Ce9zC)Bbxef_JuUWr=*~f>UyKt9sT~lO*wPN|}8xm?^Gfz`r9G{8KPZPN=b*%Zu z;~7)=OSt2_ZAywiXsb_-r)SSW&RAPCq}X${Bv0&5`jfR0X+{3_d_lO=pKz- zpIl@Og1fnuxx4w-m1NYCJH1`ZEe{afAr=H1J0~gL@sbK&h@F)buOV6krr{z_u(eb1 zaV6;aXzE+~9I(V&@$QtSmh=|)c64zhwg&NbbZ~ML_m<){w{o^1h!a0A9ftBkyu|_H zIqnCfcx9JPhJoNq=cPU=pB}rfZeHxhyi6kdC zvE@;USvosHi9bN$Vn`_1MOKzey#toJ=i=e&u-rK-ODMsC;7D+CcOza12fdJp*w=CW zL7KO@3-Kb@k1o>C0J#hx_VUYhj*if;5D-TwN$kBi@h#j|9+m`GX>C_KCrbixb+s5A zBD4gj%klvt081kkmRuU^%GoO*Nm^NoTRXctn!8JzySO;mS(+~)x^%WAl=u_y`L{n_ z;BM#cKv=$~Uy)wExv~!Ke>}R>&83^;V7GMB#U0F@Y@~R-MXdXp{(ysGl%N=r?DQ40Guhc~^iyY~W`aktc33^!dzh8u?}iJc?Kz;~%g6V+V=9sXDp2n-kUNOS>qH1s(|_B4!0(ah9Sea~O`;uqBo# z!kmCXSz4n|82FclNiP1}@TVI^VxXVQfBE(i?Vrd!(68k4oxYIV@RjlZ@!&uBwld_u zcQL8{{q>dqRLYNA29v+~Qb6Q?76K+847bD6!IEL~8aG2Y9?)7oZH#zFT2Ri!4NOUYZQ zwAQk(o4sk?5lKJvl=A-}atH)m52k%J zzu8MnO=HKsh6iq{J5(RLv8sH{cO+K#>a+6 z*to~I2Sx|`xv$vStf$+*CL~eB6Lgp)b604qXM9+3dWK+JOZzPKocWe;@f}F7#L0osf1G^?jC@`!Qm)HytE`9 zfl`FQ&2)!zvO>c}<|IPS(y4#By<$?iZ~@;_y&iXJ>cf z@ufBWx==kk9|G|U_LH9O(FF+BP(x#4Z2$yrvJ&yz5}%4ms8h%?1=>uNjojK|=OEf@ zO1H7HNs~@zqPfG4??k@J)_l6xl(PBsa;>`L`Sfpe&TkN6Z8>qm-#lyhoa!`yK=Z^L5dvx9TtiU&{Y96X(V_9d)suZczPcB!W1XUGZZIUub! zZ(^UP^1k<3fI$~v$lf-WR=1A<*}!MRGPj|bT^7vP~igY5q`-_yH z82PjczlR)-(K@?Gfj>*eEmM&`rchNO_-^|L>xf(Pf;fsJ>)W0Nz=eGz(t55#=42mG z&o){POeD@L$(voSrsb~?^jL#GCoLp3;lSg@W%r6-?}OM!FJ Date: Thu, 6 Nov 2025 19:56:14 +0100 Subject: [PATCH 3/4] csatlakozas-mukodesdemodemodemo --- SerpentRace_Frontend/package-lock.json | 129 +++++- SerpentRace_Frontend/package.json | 1 + SerpentRace_Frontend/src/App.jsx | 2 + SerpentRace_Frontend/src/api/gameApi.js | 80 ++++ .../src/hooks/useGameWebSocket.js | 268 ++++++++++++ .../src/pages/Game/GameScreen.jsx | 389 +++++++++++++----- .../src/pages/Game/GameTest.jsx | 160 +++++++ SerpentRace_Frontend/src/pages/Game/Lobby.jsx | 180 +++++++- .../src/pages/Game/PlayerSetup.jsx | 115 +++++- .../src/pages/Landing/Home.jsx | 61 ++- SerpentRace_Frontend/vite.config.js | 11 + 11 files changed, 1251 insertions(+), 145 deletions(-) create mode 100644 SerpentRace_Frontend/src/api/gameApi.js create mode 100644 SerpentRace_Frontend/src/hooks/useGameWebSocket.js create mode 100644 SerpentRace_Frontend/src/pages/Game/GameTest.jsx diff --git a/SerpentRace_Frontend/package-lock.json b/SerpentRace_Frontend/package-lock.json index 02104eac..02cae938 100644 --- a/SerpentRace_Frontend/package-lock.json +++ b/SerpentRace_Frontend/package-lock.json @@ -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", diff --git a/SerpentRace_Frontend/package.json b/SerpentRace_Frontend/package.json index d06aa2d6..96947923 100644 --- a/SerpentRace_Frontend/package.json +++ b/SerpentRace_Frontend/package.json @@ -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": { diff --git a/SerpentRace_Frontend/src/App.jsx b/SerpentRace_Frontend/src/App.jsx index f22180fa..561ad3c8 100644 --- a/SerpentRace_Frontend/src/App.jsx +++ b/SerpentRace_Frontend/src/App.jsx @@ -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() { } /> } /> } /> + } /> {/* } /> */} } /> } /> diff --git a/SerpentRace_Frontend/src/api/gameApi.js b/SerpentRace_Frontend/src/api/gameApi.js new file mode 100644 index 00000000..7a512847 --- /dev/null +++ b/SerpentRace_Frontend/src/api/gameApi.js @@ -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} 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} 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} 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 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 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; + } +}; diff --git a/SerpentRace_Frontend/src/hooks/useGameWebSocket.js b/SerpentRace_Frontend/src/hooks/useGameWebSocket.js new file mode 100644 index 00000000..2c5a0cea --- /dev/null +++ b/SerpentRace_Frontend/src/hooks/useGameWebSocket.js @@ -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, + }; +}; diff --git a/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx b/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx index 30038663..107ef68e 100644 --- a/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx +++ b/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx @@ -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) + + const [path, setPath] = useState([]) + const [players, setPlayers] = useState([]) - // Generate a snake-like path with vertical spacing and vertical offsets - const generateWindingPath = () => { + // 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) - - // 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), - }) - } + + // Generate all 100 positions + while (currentNum <= totalCells) { + const row = Math.floor((currentNum - 1) / cols) + const posInRow = (currentNum - 1) % cols + const isLeftToRight = row % 2 === 0 + + // 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, + })) + + setPlayers(mappedPlayers) + }, [backendPlayers]) - // Sort players by position in descending order - const sortedPlayers = [...players].sort((a, b) => b.position - a.position) + // Listen to player movement - optimized to update only moved player + useEffect(() => { + if (!addEventListener) return - // 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 - } + const handlePlayerMoved = (moveData) => { + setPlayers(prev => + prev.map(p => + p.id === moveData.playerId + ? { ...p, position: moveData.newPosition } + : p + ) + ) + } - console.log("Generated path length:", path.length) + addEventListener('game:player-moved', handlePlayerMoved) + return () => removeEventListener('game:player-moved') + }, [addEventListener, removeEventListener]) - const getFieldStyle = (type) => { + // 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 (
+ {/* Connection Status Indicator */} +
+
+
+ + {isConnected ? '🟢 Csatlakozva' : '🔴 Kapcsolódás...'} + +
+ {error && ( +
+ ⚠️ {error} +
+ )} +
+ + {/* Game Info Bar */} + {gameState && ( +
+
+
+ 🎮 Játék kód: {gameState.gameCode || 'N/A'} +
+ {currentTurn && ( +
+ 🎯 Köron: {players.find(p => p.id === currentTurn)?.name || 'Betöltés...'} +
+ )} +
+
+ )} +
{/* Game Board */}
- {/* Háttér */} + {/* Background decoration */}
{[...Array(35)].map((_, i) => (
{ >
))}
-
- {/* Mezők */} + +
+ {/* Fields */} {path.map((field) => (
{ 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}
))}
- - {/* Game information */} - {/*
-

- Sima - Lóhere - Rossz - -

-
*/}
{/* Right sidebar */}

Játékosok

+ + {/* Empty state */} + {players.length === 0 && ( +
+
👥
+

Várakozás játékosokra...

+
+ )} + + {/* Players list */} {sortedPlayers.map((player, index) => (
+ {/* Online indicator */} + {player.isOnline && ( +
+ )} +
{player.emoji}
-
+
{player.name} + + {/* Ready indicator */} + {player.isReady && ( + + ✓ Kész + + )} + + {/* Current turn indicator */} + {currentTurn === player.id && ( + + ▶ Köre + + )} + + {/* Rank medal */} @@ -225,31 +386,33 @@ const GameScreen = () => {

Dobókocka

- Kattints a kockára dobáshoz vagy válassz egy számot az alábbiból! + Kattints a kockára dobáshoz!

- {/* Dropdown to select number 1-6 (triggers animated roll to that number) */} -
- -
- - + + + {/* Connection warning */} + {!isConnected && ( +
+ ⚠️ Nincs kapcsolat a szerverrel +
+ )}
+ + {/* Debug Info Panel (Development only) */} + {import.meta.env.DEV && ( +
+

🔧 Debug Info

+
+
📡 Connected: {isConnected ? '✅' : '❌'}
+
🎮 Game Code: {gameState?.gameCode || 'N/A'}
+
👥 Players: {backendPlayers?.length || 0}
+
🎲 Board Fields: {boardData?.fields?.length || 0}
+
🏁 Current Turn: {currentTurn || 'N/A'}
+
🔑 Token: {gameToken ? '✅' : '❌'}
+
+
+ )}
diff --git a/SerpentRace_Frontend/src/pages/Game/GameTest.jsx b/SerpentRace_Frontend/src/pages/Game/GameTest.jsx new file mode 100644 index 00000000..7a743e28 --- /dev/null +++ b/SerpentRace_Frontend/src/pages/Game/GameTest.jsx @@ -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 ( +
+
+

Game Test

+ + {error && ( +
+ {error} +
+ )} + + {showSuccess && createdGameCode && ( +
+

Game Created!

+

+ {createdGameCode} +

+

+ Share this code with other players so they can join! +

+

+ Redirecting to game in 3 seconds... +

+
+ )} + +
+ + +
OR
+ +
+ setGameCode(e.target.value)} + placeholder="Enter Game Code" + className="w-full bg-gray-700 text-white px-4 py-2 rounded mb-2" + /> + +
+
+ +
+

Quick Access (Dev Only):

+ +
+
+
+ ); +}; + +export default GameTest; diff --git a/SerpentRace_Frontend/src/pages/Game/Lobby.jsx b/SerpentRace_Frontend/src/pages/Game/Lobby.jsx index a1d6368c..fdc8df58 100644 --- a/SerpentRace_Frontend/src/pages/Game/Lobby.jsx +++ b/SerpentRace_Frontend/src/pages/Game/Lobby.jsx @@ -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) @@ -11,6 +13,30 @@ const Lobby = () => { const location = useLocation() 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( @@ -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)" }} >

- {user} Lobby-ja + Játék Lobby

-

- Játékosok, akik csatlakoztak ehhez a szobához: + {/* Game Code Display */} +

+

+ Játék Kód: +

+
+

+ {gameCode} +

+ +
+

+ Oszd meg ezt a kódot másokkal, hogy csatlakozhassanak a játékhoz! +

+
+ + {/* Connection Status */} +
+ + {isConnected ? '🟢 Kapcsolódva' : '🔴 Kapcsolat megszakadt'} + +
+ +

+ Játékosok ({currentPlayers.length}):

    -
  • -
    - {getInitials(user)} -
    - {user} -
  • + {currentPlayers.length === 0 ? ( +
  • + Várakozás játékosokra... +
  • + ) : ( + currentPlayers.map((player, index) => ( +
  • +
    + {getInitials(player.name || `Player ${index + 1}`)} +
    + + {player.name || `Player ${index + 1}`} + + {player.isReady && ( + + Kész + + )} + {player.isOnline && ( + 🟢 + )} +
  • + )) + )}
-
+ {/* Role indicator */} +
+ {isGamemaster ? ( +
+

👑 Te vagy a Gamemaster!

+

Te nem játszol, csak indítod és moderálod a játékot.

+
+ ) : ( +
+

🎮 Te vagy egy Játékos!

+

Várj, amíg a gamemaster elindítja a játékot.

+
+ )} +
+ +
+ {isGamemaster ? ( + /* Gamemaster view - can start game */ + + ) : ( + /* Player view - cannot start game, just wait */ +
+

Várakozás a gamemaster-re...

+

Csak a gamemaster indíthatja el a játékot

+
+ )} diff --git a/SerpentRace_Frontend/src/pages/Game/PlayerSetup.jsx b/SerpentRace_Frontend/src/pages/Game/PlayerSetup.jsx index 884b4516..c2c90e8d 100644 --- a/SerpentRace_Frontend/src/pages/Game/PlayerSetup.jsx +++ b/SerpentRace_Frontend/src/pages/Game/PlayerSetup.jsx @@ -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. + {error && ( +
+ {error} +
+ )} + + {createdGameCode && ( +
+

Játék Létrehozva! 🎉

+

+ {createdGameCode} +

+

+ Oszd meg ezt a kódot más játékosokkal, hogy csatlakozhassanak! +

+

+ Átirányítás a lobby-hoz 3 másodperc múlva... +

+
+ )} +
{/* Max Players */}
@@ -115,11 +200,17 @@ const GameLobbySetup = () => {
navigate("/choose-deck")} + onClick={() => navigate("/choosedeck")} width="w-auto px-8" className="bg-gray-600 hover:bg-gray-700" + disabled={loading} + /> + -
diff --git a/SerpentRace_Frontend/src/pages/Landing/Home.jsx b/SerpentRace_Frontend/src/pages/Landing/Home.jsx index 5dc095bc..b0da054a 100644 --- a/SerpentRace_Frontend/src/pages/Landing/Home.jsx +++ b/SerpentRace_Frontend/src/pages/Landing/Home.jsx @@ -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 diff --git a/SerpentRace_Frontend/vite.config.js b/SerpentRace_Frontend/vite.config.js index bff480b0..59f06db4 100644 --- a/SerpentRace_Frontend/vite.config.js +++ b/SerpentRace_Frontend/vite.config.js @@ -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: { From 43c53076c56355df535578a4637ed075ece37611 Mon Sep 17 00:00:00 2001 From: magdo Date: Fri, 7 Nov 2025 20:00:24 +0100 Subject: [PATCH 4/4] error corrected --- .../src/Infrastructure/Repository/GameRepository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SerpentRace_Backend/src/Infrastructure/Repository/GameRepository.ts b/SerpentRace_Backend/src/Infrastructure/Repository/GameRepository.ts index e8a79e23..d16dfbc0 100644 --- a/SerpentRace_Backend/src/Infrastructure/Repository/GameRepository.ts +++ b/SerpentRace_Backend/src/Infrastructure/Repository/GameRepository.ts @@ -397,7 +397,7 @@ export class GameRepository implements IGameRepository { if (state === GameState.FINISHED) { updateData.enddate = new Date(); if (winner) { - updateData.winner = winner; + updateData.winnerId = winner; } }