diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 2616cff..2980693 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import Lobby from '../views/Lobby.vue'; import Game from '../views/Game.vue'; import Dashboard from '../views/Dashboard.vue'; +import DemoGame from '../views/DemoGame.vue'; const router = createRouter({ history: createWebHistory(), @@ -16,6 +17,11 @@ const router = createRouter({ name: 'Game', component: Game }, + { + path: '/demo', + name: 'DemoGame', + component: DemoGame + }, { path: '/dashboard', name: 'Dashboard', @@ -24,4 +30,4 @@ const router = createRouter({ ] }); -export default router; \ No newline at end of file +export default router; diff --git a/client/src/services/colyseus.ts b/client/src/services/colyseus.ts index ddd4a95..da95476 100644 --- a/client/src/services/colyseus.ts +++ b/client/src/services/colyseus.ts @@ -145,6 +145,55 @@ class ColyseusService { } } + // Demo game helpers + setVariant(variant: string): void { + if (this.gameRoom.value) { + this.gameRoom.value.send("setVariant", variant); + } + } + + p2Force(force: boolean): void { + if (this.gameRoom.value) { + this.gameRoom.value.send("p2Force", force); + } + } + + p1Action(action: 'offer' | 'no_offer' | 'forced_offer'): void { + if (this.gameRoom.value) { + this.gameRoom.value.send("p1Action", action); + } + } + + p2Action(action: 'accept' | 'reject' | 'snatch'): void { + if (this.gameRoom.value) { + this.gameRoom.value.send("p2Action", action); + } + } + + report(report: boolean): void { + if (this.gameRoom.value) { + this.gameRoom.value.send("report", report); + } + } + + assignShame(assign: boolean): void { + if (this.gameRoom.value) { + this.gameRoom.value.send("assignShame", assign); + } + } + + proposeOffer(offerPavo: number, offerElote: number, requestPavo: number, requestElote: number): void { + if (this.gameRoom.value) { + this.gameRoom.value.send("proposeOffer", { offerPavo, offerElote, requestPavo, requestElote }); + } + } + + noOffer(): void { + if (this.gameRoom.value) { + this.gameRoom.value.send("noOffer"); + } + } + leaveLobby(): void { console.log('leaveLobby called'); if (this.lobbyRoom.value) { diff --git a/client/src/views/DemoGame.vue b/client/src/views/DemoGame.vue new file mode 100644 index 0000000..1222fd1 --- /dev/null +++ b/client/src/views/DemoGame.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/client/src/views/Lobby.vue b/client/src/views/Lobby.vue index 1827b8c..d6db03f 100644 --- a/client/src/views/Lobby.vue +++ b/client/src/views/Lobby.vue @@ -23,7 +23,7 @@
@@ -156,8 +156,8 @@ async function handleQuickPlay() { colyseusService.lobbyRoom.value = null; } - console.log('Navigating to /game...'); - await router.push('/game'); + console.log('Navigating to /demo...'); + await router.push('/demo'); console.log('Navigation complete'); } catch (error) { console.error('Failed to join game:', error); @@ -421,4 +421,4 @@ async function joinRoom(roomId: string) { color: #666; font-size: 14px; } - \ No newline at end of file + diff --git a/client/src/views/games/G1.vue b/client/src/views/games/G1.vue new file mode 100644 index 0000000..f5df698 --- /dev/null +++ b/client/src/views/games/G1.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/client/src/views/games/G2.vue b/client/src/views/games/G2.vue new file mode 100644 index 0000000..36bcd33 --- /dev/null +++ b/client/src/views/games/G2.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/client/src/views/games/G3.vue b/client/src/views/games/G3.vue new file mode 100644 index 0000000..0623e24 --- /dev/null +++ b/client/src/views/games/G3.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/client/src/views/games/G4.vue b/client/src/views/games/G4.vue new file mode 100644 index 0000000..6dd72d0 --- /dev/null +++ b/client/src/views/games/G4.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/client/src/views/games/G5.vue b/client/src/views/games/G5.vue new file mode 100644 index 0000000..b700f53 --- /dev/null +++ b/client/src/views/games/G5.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/client/src/views/games/OfferControls.vue b/client/src/views/games/OfferControls.vue new file mode 100644 index 0000000..c5bac2d --- /dev/null +++ b/client/src/views/games/OfferControls.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/g1-no-property.mmd b/g1-no-property.mmd index 11c3439..7aaa9af 100644 --- a/g1-no-property.mmd +++ b/g1-no-property.mmd @@ -1,6 +1,6 @@ flowchart TD - A1[P1: Ofrecer 5?] -->|No ofrecer| O1[10,10] + A1[P1: Proponer oferta? (pavos/elotes + pedido)] -->|No ofrecer| O1[Sin cambios] A1 -->|Ofrecer| B1[P2: Aceptar / Rechazar / Robar] - B1 -->|Aceptar| O2[15,15] - B1 -->|Rechazar| O3[10,10] - B1 -->|Robar| O4[5,20] + B1 -->|Aceptar| O2[Intercambiar según oferta/pedido] + B1 -->|Rechazar| O3[Sin cambios] + B1 -->|Robar| O4[Transferir solo lo ofrecido a P2] diff --git a/g2-counterproductive-rule.mmd b/g2-counterproductive-rule.mmd index 081d595..11e86a4 100644 --- a/g2-counterproductive-rule.mmd +++ b/g2-counterproductive-rule.mmd @@ -1,9 +1,9 @@ flowchart TD - A2[P2: Forzar?] -->|Sí| F2[P1: Oferta forzada] - A2 -->|No| B2[P1: Ofrecer 5?] + A2[P2: Forzar?] -->|Sí| F2[P1: Debe proponer oferta] + A2 -->|No| B2[P1: Proponer oferta?] F2 --> C2[P2: Acción final] - B2 -->|No ofrecer| O1[10,10] + B2 -->|No ofrecer| O1[Sin cambios] B2 -->|Ofrecer| C2[P2: Aceptar / Rechazar / Robar] - C2 -->|Aceptar| O2[15,15] - C2 -->|Rechazar| O3[10,10] - C2 -->|Robar| O4[5,20] + C2 -->|Aceptar| O2[Intercambiar según oferta/pedido] + C2 -->|Rechazar| O3[Sin cambios] + C2 -->|Robar| O4[Transferir solo lo ofrecido a P2] diff --git a/g3-shame-token.mmd b/g3-shame-token.mmd index 2fa8132..4cbe390 100644 --- a/g3-shame-token.mmd +++ b/g3-shame-token.mmd @@ -1,8 +1,8 @@ flowchart TD - A3[P1: Ofrecer 5?] -->|No ofrecer| O1[10,10] + A3[P1: Proponer oferta?] -->|No ofrecer| O1[Sin cambios] A3 -->|Ofrecer| B3[P2: Aceptar / Rechazar / Robar] - B3 -->|Aceptar| O2[15,15] - B3 -->|Rechazar| O3[10,10] - B3 -->|Robar| C3[P1: Asignar ficha de verguenza?] - C3 -->|Sí| O4a[5,20 +1 verguenza proxima partida] - C3 -->|No| O4b[5,20] + B3 -->|Aceptar| O2[Intercambiar según oferta/pedido] + B3 -->|Rechazar| O3[Sin cambios] + B3 -->|Robar| C3[P1: Asignar ficha de vergüenza?] + C3 -->|Sí| O4a[+1 vergüenza para P2] + C3 -->|No| O4b[Sin vergüenza] diff --git a/g4-min-property-rights.mmd b/g4-min-property-rights.mmd index e75c1e7..a9ad18d 100644 --- a/g4-min-property-rights.mmd +++ b/g4-min-property-rights.mmd @@ -1,9 +1,9 @@ flowchart TD - A4[P1: Ofrecer 5?] -->|No ofrecer| O1[10,10] + A4[P1: Proponer oferta?] -->|No ofrecer| O1[Sin cambios] A4 -->|Ofrecer| B4[P2: Aceptar / Rechazar / Robar] - B4 -->|Aceptar| O2[15,15] - B4 -->|Rechazar| O3[10,10] + B4 -->|Aceptar| O2[Intercambiar según oferta/pedido] + B4 -->|Rechazar| O3[Sin cambios] B4 -->|Robar| C4[P1: ¿Denunciar?] - C4 -->|No| O4[5,20] - C4 -->|Sí| J4[AutoJudge confisca tokens P2] - J4 --> O5[10,0] + C4 -->|No| O4[Transferir solo lo ofrecido a P2] + C4 -->|Sí| J4[AutoJudge revierte robo (confisca oferta a P2)] + J4 --> O5[Restituir oferta a P1] diff --git a/g5-cheap-talk.mmd b/g5-cheap-talk.mmd index bc2a4ee..01782a7 100644 --- a/g5-cheap-talk.mmd +++ b/g5-cheap-talk.mmd @@ -1,7 +1,7 @@ flowchart TD - Pre[Chat previo 1 min - no vinculante] --> A5[P1: Ofrecer 5?] - A5 -->|No ofrecer| O1[10,10] + Pre[Chat previo 1 min - no vinculante] --> A5[P1: Proponer oferta?] + A5 -->|No ofrecer| O1[Sin cambios] A5 -->|Ofrecer| B5[P2: Aceptar / Rechazar / Robar] - B5 -->|Aceptar| O2[15,15] - B5 -->|Rechazar| O3[10,10] - B5 -->|Robar| O4[5,20] + B5 -->|Aceptar| O2[Intercambiar según oferta/pedido] + B5 -->|Rechazar| O3[Sin cambios] + B5 -->|Robar| O4[Transferir solo lo ofrecido a P2] diff --git a/game-design-mermaid.md b/game-design-mermaid.md index 8e8d388..c538fb2 100644 --- a/game-design-mermaid.md +++ b/game-design-mermaid.md @@ -102,110 +102,106 @@ sequenceDiagram Note over P1,P2: Sin decision previa de P2 end - P1->>S: actionP1(offer or no_offer) - o forzado en G2 - S-->>P2: notifyP1Action(offer or no_offer) - alt no_offer - S-->>P1: outcome(10,10) - S-->>P2: outcome(10,10) - else offer + P1->>S: noOffer() + S-->>P1: sin cambios de tokens + S-->>P2: sin cambios de tokens + else oferta + P1->>S: proposeOffer({offer:{pavo,elote}, request:{pavo,elote}}) + S-->>P2: offerAvailable P2->>S: actionP2(accept / reject / snatch) alt accept - S-->>P1: outcome(15,15) - S-->>P2: outcome(15,15) + S-->>P1: transfer ambos lados (según oferta/pedido) + S-->>P2: transfer ambos lados (según oferta/pedido) else reject - S-->>P1: outcome(10,10) - S-->>P2: outcome(10,10) + S-->>P1: sin cambios + S-->>P2: sin cambios else snatch + S-->>P2: transferir solo lo ofrecido a P2 opt G4 denuncia P1->>S: report: yes or no alt report=yes - S->>AJ: aplicar sancion - AJ-->>S: confiscar tokens P2 - S-->>P1: outcome(10,0) - S-->>P2: outcome(10,0) + S->>AJ: aplicar sanción + AJ-->>S: confiscar oferta a P2 y revertir a P1 else report=no - S-->>P1: outcome(5,20) - S-->>P2: outcome(5,20) + Note over P1,P2: Se mantiene el robo end + end opt G3 repudio P1->>S: shameToken: assign yes or no - S-->>P2: actualizar contador verguenza (proxima partida) + S-->>P2: actualizar contador vergüenza (próxima partida) end end end - opt Round3 - persistir resultado R3 - S->>S: actualizar leaderboard y analytics - end S-->>P1: endRound S-->>P2: endRound ``` ## Variantes de juego -### G1 – Sin derechos de propiedad +### G1 – Sin derechos de propiedad (oferta variable) ```mermaid %% g1-no-property.mmd flowchart TD - A1[P1: Ofrecer 5?] -->|No ofrecer| O1[10,10] + A1[P1: Proponer oferta? (pavos/elotes + pedido)] -->|No ofrecer| O1[Sin cambios] A1 -->|Ofrecer| B1[P2: Aceptar / Rechazar / Robar] - B1 -->|Aceptar| O2[15,15] - B1 -->|Rechazar| O3[10,10] - B1 -->|Robar| O4[5,20] + B1 -->|Aceptar| O2[Intercambiar según oferta/pedido] + B1 -->|Rechazar| O3[Sin cambios] + B1 -->|Robar| O4[Transferir solo lo ofrecido a P2] ``` -### G2 – Regla contraproductiva (P2 puede forzar) +### G2 – Regla contraproductiva (P2 puede forzar) – oferta variable ```mermaid %% g2-counterproductive-rule.mmd flowchart TD - A2[P2: Forzar?] -->|Sí| F2[P1: Oferta forzada] - A2 -->|No| B2[P1: Ofrecer 5?] + A2[P2: Forzar?] -->|Sí| F2[P1: Debe proponer oferta] + A2 -->|No| B2[P1: Proponer oferta?] F2 --> C2[P2: Acción final] - B2 -->|No ofrecer| O1[10,10] + B2 -->|No ofrecer| O1[Sin cambios] B2 -->|Ofrecer| C2[P2: Aceptar / Rechazar / Robar] - C2 -->|Aceptar| O2[15,15] - C2 -->|Rechazar| O3[10,10] - C2 -->|Robar| O4[5,20] + C2 -->|Aceptar| O2[Intercambiar según oferta/pedido] + C2 -->|Rechazar| O3[Sin cambios] + C2 -->|Robar| O4[Transferir solo lo ofrecido a P2] ``` -### G3 – Token de repudio (vergüenza) +### G3 – Token de repudio (vergüenza) – oferta variable ```mermaid %% g3-shame-token.mmd flowchart TD - A3[P1: Ofrecer 5?] -->|No ofrecer| O1[10,10] + A3[P1: Proponer oferta?] -->|No ofrecer| O1[Sin cambios] A3 -->|Ofrecer| B3[P2: Aceptar / Rechazar / Robar] - B3 -->|Aceptar| O2[15,15] - B3 -->|Rechazar| O3[10,10] - B3 -->|Robar| C3[P1: Asignar ficha de verguenza?] - C3 -->|Sí| O4a[5,20 +1 verguenza proxima partida] - C3 -->|No| O4b[5,20] + B3 -->|Aceptar| O2[Intercambiar según oferta/pedido] + B3 -->|Rechazar| O3[Sin cambios] + B3 -->|Robar| C3[P1: Asignar ficha de vergüenza?] + C3 -->|Sí| O4a[+1 vergüenza para P2] + C3 -->|No| O4b[Sin vergüenza] ``` -### G4 – Derechos mínimos de propiedad (juez) +### G4 – Derechos mínimos de propiedad (juez) – oferta variable ```mermaid %% g4-min-property-rights.mmd flowchart TD - A4[P1: Ofrecer 5?] -->|No ofrecer| O1[10,10] + A4[P1: Proponer oferta?] -->|No ofrecer| O1[Sin cambios] A4 -->|Ofrecer| B4[P2: Aceptar / Rechazar / Robar] - B4 -->|Aceptar| O2[15,15] - B4 -->|Rechazar| O3[10,10] + B4 -->|Aceptar| O2[Intercambiar según oferta/pedido] + B4 -->|Rechazar| O3[Sin cambios] B4 -->|Robar| C4[P1: ¿Denunciar?] - C4 -->|No| O4[5,20] - C4 -->|Sí| J4[Juez confisca tokens P2] - J4 --> O5[10,0] + C4 -->|No| O4[Transferir solo lo ofrecido a P2] + C4 -->|Sí| J4[AutoJudge revierte robo (confisca oferta a P2)] + J4 --> O5[Restituir oferta a P1] ``` -### G5 – Cheap talk (conversación previa) +### G5 – Cheap talk (conversación previa) – oferta variable ```mermaid %% g5-cheap-talk.mmd flowchart TD - Pre[Chat previo 1 min - no vinculante] --> A5[P1: Ofrecer 5?] - A5 -->|No ofrecer| O1[10,10] + Pre[Chat previo 1 min - no vinculante] --> A5[P1: Proponer oferta?] + A5 -->|No ofrecer| O1[Sin cambios] A5 -->|Ofrecer| B5[P2: Aceptar / Rechazar / Robar] - B5 -->|Aceptar| O2[15,15] - B5 -->|Rechazar| O3[10,10] - B5 -->|Robar| O4[5,20] + B5 -->|Aceptar| O2[Intercambiar según oferta/pedido] + B5 -->|Rechazar| O3[Sin cambios] + B5 -->|Robar| O4[Transferir solo lo ofrecido a P2] ``` ## Emparejamiento en masa (fase Gx) diff --git a/server/src/rooms/GameRoom.ts b/server/src/rooms/GameRoom.ts index d58b003..f707483 100644 --- a/server/src/rooms/GameRoom.ts +++ b/server/src/rooms/GameRoom.ts @@ -6,16 +6,137 @@ import { NameManager } from "../utils/nameManager"; export class GameRoom extends Room { maxClients = 2; private gameInterval?: NodeJS.Timeout; - private readonly TICK_RATE = 1000; // Update every second onCreate(options: any) { this.setState(new GameState()); this.state.roomId = this.roomId; - this.onMessage("click", (client) => { - this.handleClick(client); + // Variant selection (both players can change) + this.onMessage("setVariant", (client, variant: string) => { + this.state.currentVariant = variant; + // Reset to round 1 and clear decisions when variant changes + this.state.currentRound = 1; + this.state.resetRound(); + // G2: Force offer by default + if (variant === 'G2') { + this.state.forcedByP2 = true; + } + this.broadcast("variantChanged", { variant }); }); + // P1 proposes a variable offer (offer -> P2, request <- from P2) + this.onMessage("proposeOffer", (client, payload: { offerPavo:number; offerElote:number; requestPavo:number; requestElote:number; }) => { + const player = this.state.players.get(client.sessionId); + if (!player || player.role !== "P1") return; + const p1 = this.state.p1Id ? this.state.players.get(this.state.p1Id) : undefined; + const p2 = this.state.p2Id ? this.state.players.get(this.state.p2Id) : undefined; + if (!p1 || !p2) return; + + const oPavo = Math.max(0, Math.floor(payload.offerPavo || 0)); + const oElote = Math.max(0, Math.floor(payload.offerElote || 0)); + const rPavo = Math.max(0, Math.floor(payload.requestPavo || 0)); + const rElote = Math.max(0, Math.floor(payload.requestElote || 0)); + + // Validate holdings: P1 must have offered tokens; P2 must have requested tokens + if (oPavo > p1.pavoTokens) return; + if (oElote > p1.eloteTokens) return; + if (rPavo > p2.pavoTokens) return; + if (rElote > p2.eloteTokens) return; + + // Clear any previous state before setting new offer + this.state.resetRound(); + + this.state.offerPavo = oPavo; + this.state.offerElote = oElote; + this.state.requestPavo = rPavo; + this.state.requestElote = rElote; + this.state.offerActive = true; // Always set active when an offer is proposed + this.state.p1Action = "offer"; + }); + + // P1 decides to not offer + this.onMessage("noOffer", (client) => { + const player = this.state.players.get(client.sessionId); + if (!player || player.role !== "P1") return; + if (this.state.forcedByP2) return; // cannot refuse if forced in G2 + if (this.state.offerActive) return; // Can't "no offer" if offer is already active + + this.state.resetRound(); + this.state.p1Action = "no_offer"; + // Auto-advance to next round when P1 doesn't offer + this.advanceRound(); + }); + + // G2: P2 may force an offer + this.onMessage("p2Force", (client, force: boolean) => { + const player = this.state.players.get(client.sessionId); + if (!player) return; + if (player.role !== "P2") return; + this.state.forcedByP2 = !!force; + // When forced, P1 must propose an offer; nothing automatic here. + }); + + // P2 action + this.onMessage("p2Action", (client, action: string) => { + const player = this.state.players.get(client.sessionId); + if (!player) return; + if (player.role !== "P2") return; + this.state.p2Action = action; // accept | reject | snatch + this.resolveP2Action(); + + // Auto-advance unless it's a snatch in G3 or G4 (need shame/report) + if (action !== 'snatch' || (this.state.currentVariant !== 'G3' && this.state.currentVariant !== 'G4')) { + this.advanceRound(); + } + }); + + // G4 report after snatch + this.onMessage("report", (client, report: boolean) => { + const player = this.state.players.get(client.sessionId); + if (!player) return; + if (player.role !== "P1") return; + this.state.reported = !!report; + if (report && this.state.currentVariant === "G4" && this.state.p2Action === "snatch") { + // Inverse of snatch: P1 gets requested without giving offered + const p1 = this.state.p1Id ? this.state.players.get(this.state.p1Id) : undefined; + const p2 = this.state.p2Id ? this.state.players.get(this.state.p2Id) : undefined; + if (p1 && p2) { + // First, revert the snatch (return offered tokens to P1) + const oP = this.state.offerPavo; + const oE = this.state.offerElote; + if (p2.pavoTokens >= oP) { p2.pavoTokens -= oP; p1.pavoTokens += oP; } + if (p2.eloteTokens >= oE) { p2.eloteTokens -= oE; p1.eloteTokens += oE; } + + // Then apply the sanction: P1 gets requested without giving anything + const rP = this.state.requestPavo; + const rE = this.state.requestElote; + if (p2.pavoTokens >= rP) { p2.pavoTokens -= rP; p1.pavoTokens += rP; } + if (p2.eloteTokens >= rE) { p2.eloteTokens -= rE; p1.eloteTokens += rE; } + } + // Clear offer now + this.clearOffer(); + } + // Auto-advance after report decision + this.advanceRound(); + }); + + // G3 shame token after snatch + this.onMessage("assignShame", (client, assign: boolean) => { + const player = this.state.players.get(client.sessionId); + if (!player) return; + if (player.role !== "P1") return; + this.state.shameAssigned = !!assign; + if (assign && this.state.currentVariant === "G3" && this.state.p2Action === "snatch") { + // increment P2 shame immediately + const p2 = this.state.p2Id ? this.state.players.get(this.state.p2Id) : undefined; + if (p2) p2.shameTokens += 1; + } + // Auto-advance after shame decision + this.advanceRound(); + }); + + // Removed nextRound handler - rounds now auto-advance + this.onMessage("admin:pause", () => { this.state.pauseGame(); }); @@ -39,7 +160,7 @@ export class GameRoom extends Room { // Use the playerName passed from the lobby - don't generate a new one! const playerName = options.playerName || "player"; - this.state.addPlayer(client.sessionId, playerName); + const player = this.state.addPlayer(client.sessionId, playerName); client.send("playerInfo", { sessionId: client.sessionId, @@ -94,18 +215,13 @@ export class GameRoom extends Room { } private startGame() { - console.log(`[GameRoom] Starting game in room ${this.roomId}`); - + console.log(`[GameRoom] Starting demo game in room ${this.roomId}`); this.state.startGame(); + // G2: Force offer by default when starting game + if (this.state.currentVariant === 'G2') { + this.state.forcedByP2 = true; + } this.broadcast("gameStart"); - - this.gameInterval = setInterval(() => { - this.state.updateTimer(this.TICK_RATE / 1000); - - if (this.state.gameStatus === GameStatus.FINISHED) { - this.endGame(); - } - }, this.TICK_RATE); } private pauseGame() { @@ -115,38 +231,56 @@ export class GameRoom extends Room { } private endGame() { - console.log(`[GameRoom] Game ended in room ${this.roomId}. Winner: ${this.state.winner}`); - - if (this.gameInterval) { - clearInterval(this.gameInterval); - this.gameInterval = undefined; - } - - this.broadcast("gameEnd", { - winner: this.state.winner, - players: Array.from(this.state.players.values()).map(p => ({ - name: p.name, - clicks: p.clicks - })) - }); - - setTimeout(() => { - this.state.restartGame(); - if (this.state.players.size === 2) { - this.startGame(); - } - }, 5000); + console.log(`[GameRoom] Demo game ended in room ${this.roomId}`); + this.broadcast("gameEnd", {}); } + + private resolveP2Action() { + const p1 = this.state.p1Id ? this.state.players.get(this.state.p1Id) : undefined; + const p2 = this.state.p2Id ? this.state.players.get(this.state.p2Id) : undefined; + if (!p1 || !p2) return; + const { p2Action, offerActive } = this.state; + if (!offerActive && this.state.p1Action !== 'no_offer') return; - private handleClick(client: Client) { - if (this.state.gameStatus !== GameStatus.PLAYING) { + if (this.state.p1Action === 'no_offer') { + // Nothing to transfer; round can proceed. return; } - const player = this.state.players.get(client.sessionId); - if (player && player.connected) { - player.incrementClicks(); + if (p2Action === 'accept') { + // Transfer P1 -> P2 (offered) + if (p1.pavoTokens >= this.state.offerPavo && p1.eloteTokens >= this.state.offerElote && + p2.pavoTokens >= this.state.requestPavo && p2.eloteTokens >= this.state.requestElote) { + p1.pavoTokens -= this.state.offerPavo; p2.pavoTokens += this.state.offerPavo; + p1.eloteTokens -= this.state.offerElote; p2.eloteTokens += this.state.offerElote; + // Transfer P2 -> P1 (requested) + p2.pavoTokens -= this.state.requestPavo; p1.pavoTokens += this.state.requestPavo; + p2.eloteTokens -= this.state.requestElote; p1.eloteTokens += this.state.requestElote; + } + this.clearOffer(); } + else if (p2Action === 'reject') { + // No changes + this.clearOffer(); + } + else if (p2Action === 'snatch') { + // Transfer only offered P1 -> P2 + if (p1.pavoTokens >= this.state.offerPavo && p1.eloteTokens >= this.state.offerElote) { + p1.pavoTokens -= this.state.offerPavo; p2.pavoTokens += this.state.offerPavo; + p1.eloteTokens -= this.state.offerElote; p2.eloteTokens += this.state.offerElote; + } + // Keep offer data around for potential G4 report; it will be cleared on report or next round + } + } + + private clearOffer() { + this.state.offerPavo = 0; + this.state.offerElote = 0; + this.state.requestPavo = 0; + this.state.requestElote = 0; + this.state.offerActive = false; + this.state.p1Action = ""; + this.state.p2Action = ""; } private handleRestart() { @@ -161,7 +295,7 @@ export class GameRoom extends Room { this.broadcast("gameRestart"); if (this.state.players.size === 2) { - setTimeout(() => this.startGame(), 2000); + setTimeout(() => this.startGame(), 500); } } @@ -188,11 +322,40 @@ export class GameRoom extends Room { players: Array.from(this.state.players.values()).map(p => ({ sessionId: p.sessionId, name: p.name, - clicks: p.clicks + role: p.role, + pavoTokens: p.pavoTokens, + eloteTokens: p.eloteTokens, + shameTokens: p.shameTokens, })), gameStatus: this.state.gameStatus, - timeRemaining: this.state.timeRemaining, - winner: this.state.winner + variant: this.state.currentVariant, + round: this.state.currentRound, + decisions: { + p1Action: this.state.p1Action, + p2Action: this.state.p2Action, + forcedByP2: this.state.forcedByP2, + reported: this.state.reported, + shameAssigned: this.state.shameAssigned, + offer: { + offerPavo: this.state.offerPavo, + offerElote: this.state.offerElote, + requestPavo: this.state.requestPavo, + requestElote: this.state.requestElote, + active: this.state.offerActive, + } + }, + outcome: {} }; } -} \ No newline at end of file + + private advanceRound() { + if (this.state.currentRound < 3) { + this.state.currentRound += 1; + this.state.resetRound(); + this.broadcast("roundStarted", { round: this.state.currentRound }); + } else { + this.state.finishGame(); + this.endGame(); + } + } +} diff --git a/server/src/rooms/schemas/GameState.ts b/server/src/rooms/schemas/GameState.ts index 605010f..16ca458 100644 --- a/server/src/rooms/schemas/GameState.ts +++ b/server/src/rooms/schemas/GameState.ts @@ -5,17 +5,47 @@ import { GameStatus } from "../../../../shared/types"; export class GameState extends Schema { @type({ map: Player }) players = new MapSchema(); @type("string") gameStatus: GameStatus = GameStatus.WAITING; - @type("number") timeRemaining: number = 600; // 10 minutes in seconds + @type("number") timeRemaining: number = 0; @type("string") winner: string = ""; @type("number") startTime: number = 0; @type("string") roomId: string = ""; + // Roles + @type("string") p1Id: string = ""; + @type("string") p2Id: string = ""; + + // Variant & round + @type("string") currentVariant: string = "G1"; // G1..G5 + @type("number") currentRound: number = 1; // 1..3 + + // Decisions & flags for current round + @type("string") p1Action: string = ""; // no_offer|"" (variable offers handled via fields below) + @type("string") p2Action: string = ""; // accept|reject|snatch + @type("boolean") forcedByP2: boolean = false; // G2 + @type("boolean") reported: boolean = false; // G4 + @type("boolean") shameAssigned: boolean = false; // G3 + + // Offer payload (P1 -> P2) and requested return (P2 -> P1) + @type("number") offerPavo: number = 0; + @type("number") offerElote: number = 0; + @type("number") requestPavo: number = 0; + @type("number") requestElote: number = 0; + @type("boolean") offerActive: boolean = false; + constructor() { super(); } addPlayer(sessionId: string, name: string): Player { const player = new Player(sessionId, name); + // Assign roles P1/P2 in join order + if (!this.p1Id) { + this.p1Id = sessionId; + player.role = "P1"; + } else if (!this.p2Id) { + this.p2Id = sessionId; + player.role = "P2"; + } this.players.set(sessionId, player); return player; } @@ -27,8 +57,18 @@ export class GameState extends Schema { startGame(): void { this.gameStatus = GameStatus.PLAYING; this.startTime = Date.now(); - this.timeRemaining = 600; + this.timeRemaining = 0; this.resetAllPlayers(); + // Initialize tokens by role + if (this.p1Id) { + const p1 = this.players.get(this.p1Id); + if (p1) { p1.pavoTokens = 10; p1.eloteTokens = 0; } + } + if (this.p2Id) { + const p2 = this.players.get(this.p2Id); + if (p2) { p2.eloteTokens = 10; p2.pavoTokens = 0; } + } + this.resetRound(); } pauseGame(): void { @@ -45,14 +85,19 @@ export class GameState extends Schema { finishGame(): void { this.gameStatus = GameStatus.FINISHED; - this.determineWinner(); } restartGame(): void { this.gameStatus = GameStatus.WAITING; - this.timeRemaining = 600; + this.timeRemaining = 0; this.winner = ""; this.startTime = 0; + this.currentRound = 1; + this.p1Action = this.p2Action = ""; + this.forcedByP2 = this.reported = this.shameAssigned = false; + this.offerPavo = this.offerElote = 0; + this.requestPavo = this.requestElote = 0; + this.offerActive = false; this.resetAllPlayers(); } @@ -60,27 +105,14 @@ export class GameState extends Schema { this.players.forEach(player => player.reset()); } - private determineWinner(): void { - let maxClicks = -1; - let winner = ""; - - this.players.forEach(player => { - if (player.clicks > maxClicks) { - maxClicks = player.clicks; - winner = player.name; - } - }); - - this.winner = winner; + resetRound(): void { + this.p1Action = ""; + this.p2Action = ""; + this.forcedByP2 = (this.currentVariant === "G2"); + this.reported = false; + this.shameAssigned = false; + this.offerPavo = this.offerElote = 0; + this.requestPavo = this.requestElote = 0; + this.offerActive = false; } - - updateTimer(deltaTime: number): void { - if (this.gameStatus === GameStatus.PLAYING && this.timeRemaining > 0) { - this.timeRemaining -= deltaTime; - if (this.timeRemaining <= 0) { - this.timeRemaining = 0; - this.finishGame(); - } - } - } -} \ No newline at end of file +} diff --git a/server/src/rooms/schemas/Player.ts b/server/src/rooms/schemas/Player.ts index 5237385..54428ac 100644 --- a/server/src/rooms/schemas/Player.ts +++ b/server/src/rooms/schemas/Player.ts @@ -5,6 +5,10 @@ export class Player extends Schema { @type("string") name: string = ""; @type("number") clicks: number = 0; @type("boolean") connected: boolean = true; + @type("string") role: string = ""; // 'P1' | 'P2' + @type("number") pavoTokens: number = 0; + @type("number") eloteTokens: number = 0; + @type("number") shameTokens: number = 0; constructor(sessionId: string, name: string) { super(); @@ -12,6 +16,10 @@ export class Player extends Schema { this.name = name; this.clicks = 0; this.connected = true; + this.role = ""; + this.pavoTokens = 0; + this.eloteTokens = 0; + this.shameTokens = 0; } incrementClicks(): void { @@ -20,5 +28,7 @@ export class Player extends Schema { reset(): void { this.clicks = 0; + this.pavoTokens = 0; + this.eloteTokens = 0; } -} \ No newline at end of file +}