feat: implement variable token offers and auto-round advancement
- Add variable offer system where P1 can offer any amount of tokens - Players start with 10 tokens each (P1: pavos, P2: elotes) - Implement offer/request mechanism with token validation - Auto-advance rounds after P2 actions or P1 no-offer - G2: Force offer by default, disable no-offer button when forced - G3: Wait for shame decision after snatch before advancing - G4: Implement inverse sanction (P1 gets requested without giving offered) - Reset rounds to 1 when changing game variants - Fix OfferControls responsiveness issues - Hide offer controls after active offer - Update all G1-G5 components with proper offer flow
This commit is contained in:
@@ -6,16 +6,137 @@ import { NameManager } from "../utils/nameManager";
|
||||
export class GameRoom extends Room<GameState> {
|
||||
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<GameState> {
|
||||
// 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<GameState> {
|
||||
}
|
||||
|
||||
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<GameState> {
|
||||
}
|
||||
|
||||
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<GameState> {
|
||||
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<GameState> {
|
||||
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: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,47 @@ import { GameStatus } from "../../../../shared/types";
|
||||
export class GameState extends Schema {
|
||||
@type({ map: Player }) players = new MapSchema<Player>();
|
||||
@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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user