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 @@
+
+
+
+
+
+
+
+
{{ p.name }}
+
Role: {{ p.role || '—' }}
+
+ 🦃 {{ p.pavoTokens }}
+ 🌽 {{ p.eloteTokens }}
+ 😶 {{ p.shameTokens }}
+
+
+
+
+
+
+
+
Waiting for opponent...
+
Players in room: {{ players.length }}/2
+
+
+
+
+
+
+
+
+
Outcome P1: {{ outcomeP1 }}
+
Outcome P2: {{ outcomeP2 }}
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
G1 – Sin derechos de propiedad
+
+
+
Oferta: 🦃 {{ state.offer.offerPavo }} / 🌽 {{ state.offer.offerElote }} | Pedido: 🦃 {{ state.offer.requestPavo }} / 🌽 {{ state.offer.requestElote }}
+
+
+
+
+
+
Esperando decisión de P2…
+
+
+
+
+
+
+
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 @@
+
+
+
G2 – Regla contraproductiva (P2 puede forzar)
+
+
+
+
+
Oferta activa
+
+
+
+
+
+
+
Esperando decisión de P2…
+
+
+
+
+
+
+
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 @@
+
+
+
G3 – Token de repudio (vergüenza)
+
+
+
+
+
+
+
+
Esperando decisión de P2…
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
G4 – Derechos mÃnimos de propiedad (juez)
+
+
+
+
+
+
+
+
Esperando decisión de P2…
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
G5 – Cheap talk (chat previo no vinculante)
+
+
+
+
+
+
+
+
+
+
+
+
Esperando decisión de P2…
+
+
+
+
+
+
+
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
+}