655 lines
23 KiB
TypeScript
655 lines
23 KiB
TypeScript
import { Client, Room } from 'colyseus';
|
|
import { Dispatcher } from '@colyseus/command';
|
|
import { createNanoEvents } from 'nanoevents';
|
|
import { Player as CardPlayer, Hand } from 'typedeck';
|
|
|
|
import {
|
|
GameState,
|
|
Player,
|
|
CardPlaceholder,
|
|
SerializedCard,
|
|
CardValue,
|
|
PromptButton,
|
|
PromptField,
|
|
Prompt,
|
|
} from '../games/state';
|
|
|
|
import {
|
|
SetUserStashCommand,
|
|
AddToUserStashCommand,
|
|
RemoveFromUserStashCommand,
|
|
GiveRoomOwnershipCommand,
|
|
AssignSeatCommand,
|
|
StartGameCommand,
|
|
PauseGameCommand
|
|
} from './commands/admin';
|
|
|
|
import { CartaNapoletana, SettemmezzoGameType } from '../games/banco';
|
|
import { assert } from 'console';
|
|
|
|
interface OneOffEvent {
|
|
[action: string]: (client: Client, message: any) => void
|
|
}
|
|
|
|
interface PlayerAction {
|
|
action: string
|
|
bet: number
|
|
}
|
|
|
|
export class GameRoom extends Room<GameState> {
|
|
private dispatcher = new Dispatcher(this);
|
|
private messageHandlers = createNanoEvents<OneOffEvent>();
|
|
private validateSession;
|
|
private gameType = new SettemmezzoGameType();
|
|
private deck;
|
|
private currentDealer: number = -1;
|
|
|
|
sleep = (milliseconds: number = 1000): Promise<void> => {
|
|
return new Promise<void>(resolve => {
|
|
this.clock.setTimeout(resolve, milliseconds);
|
|
});
|
|
}
|
|
|
|
getSeatedPlayers(): Player[] {
|
|
const players = Array.from<Player>(this.state.players.values());
|
|
return players.filter((p) => { return (p.playing) });
|
|
}
|
|
|
|
getPlayerBySeat(n: number): Player {
|
|
const players = Array.from<Player>(this.state.players.values());
|
|
return players.find((p) => { return (p.playing && p.seat === n) });
|
|
}
|
|
|
|
pMap(callbackFn: (value: Player, index: number, array: Player[]) => Player, thisArg?: any): Player[] {
|
|
const players = Array.from<Player>(this.state.players.values());
|
|
return players.map(callbackFn);
|
|
}
|
|
|
|
setDealer(n: number) {
|
|
this.pMap((player) => {
|
|
player.dealer = player.seat === n;
|
|
return player;
|
|
});
|
|
}
|
|
|
|
nextAvailableSeat = (): number => {
|
|
const playing = this.getSeatedPlayers();
|
|
return Math.max(...playing.map((p) => { return p.seat }), 0) + 1;
|
|
}
|
|
|
|
nextDealer = (): number => {
|
|
const players = this.getSeatedPlayers();
|
|
this.currentDealer = (this.currentDealer + 1) % players.length;
|
|
const dealerSeat = players[this.currentDealer].seat;
|
|
console.log('Setting dealer seat:', dealerSeat);
|
|
|
|
this.setDealer(dealerSeat);
|
|
return dealerSeat;
|
|
}
|
|
|
|
*nextPlayerGenerator(dealer: Player) {
|
|
const players = this.getSeatedPlayers();
|
|
const dealerIndex = players.indexOf(dealer);
|
|
let n = 1;
|
|
while (true) {
|
|
let p: Player = yield players[(dealerIndex + n) % players.length];
|
|
n += 1;
|
|
}
|
|
}
|
|
|
|
shuffleCards = () => {
|
|
console.log('Hard shuffling cards!');
|
|
this.gameType = new SettemmezzoGameType();
|
|
this.deck = this.gameType.createDeck();
|
|
this.deck.shuffle();
|
|
this.state.remainingCards = 40;
|
|
}
|
|
|
|
dealCard(player: Player, faceUp: boolean = false) {
|
|
const cardPlayer = new CardPlayer(player.displayName,
|
|
new Hand());
|
|
|
|
this.deck.deal(cardPlayer.getHand(), 1);
|
|
this.state.remainingCards -= 1;
|
|
|
|
const [card] = <CartaNapoletana[]>cardPlayer.getHand().takeCardsFromBottom(1);
|
|
console.log('Card dealt:', card, 'remaining:', this.state.remainingCards);
|
|
if (card === undefined) {
|
|
// FIXME: why this ever happens?
|
|
console.warn('Card was undefined, deck:', this.deck);
|
|
return;
|
|
}
|
|
|
|
const cardValue = new CardValue({ value: card.cardName, suit: card.suit });
|
|
const serializedCard = new SerializedCard({
|
|
owner: player.sessionId,
|
|
public: faceUp,
|
|
card: cardValue
|
|
});
|
|
|
|
player.hand.push(new CardPlaceholder({ card: serializedCard }));
|
|
}
|
|
|
|
publishCards = (player: Player) => {
|
|
player.hand.forEach(pc => {
|
|
pc.card.public = true;
|
|
});
|
|
}
|
|
|
|
tossAllHands = () => {
|
|
this.pMap((player) => {
|
|
player.hand = player.hand.filter((pc) => { return false });
|
|
player.prompt = new Prompt();
|
|
return player;
|
|
});
|
|
}
|
|
|
|
getPlayerAction(player: Player): Promise<PlayerAction> {
|
|
return new Promise<PlayerAction>(resolve => {
|
|
const unbind = this.messageHandlers.on('resolve-prompt', (client, message) => {
|
|
if (player.sessionId !== client.sessionId) {
|
|
console.log('Waiting for a different sessionId:', player.sessionId, client.sessionId);
|
|
return;
|
|
}
|
|
unbind();
|
|
|
|
console.log('Got player response:', message);
|
|
resolve(message);
|
|
});
|
|
});
|
|
}
|
|
|
|
hasJolly = (hand: CardPlaceholder[]): boolean => {
|
|
return undefined !== hand.find((pc) => {
|
|
const value = pc.card.card.value;
|
|
const suit = pc.card.card.suit;
|
|
return value === 9 && suit === 3;
|
|
});
|
|
}
|
|
|
|
calculateScore = (hand: CardPlaceholder[]) => {
|
|
let score = hand.reduce((accumulator, currentValue) => {
|
|
const cardValue = currentValue.card.card.value;
|
|
const cardSuit = currentValue.card.card.suit;
|
|
if (cardValue == 9 && cardSuit == 3) {
|
|
return accumulator + 0;
|
|
}
|
|
const v = (cardValue <= 6) ? cardValue + 1 : 0.5;
|
|
return accumulator + v;
|
|
}, 0);
|
|
|
|
const withJolly = this.hasJolly(hand);
|
|
if (withJolly) {
|
|
score += Math.floor(7.5 - score);
|
|
}
|
|
|
|
return { score, withJolly };
|
|
}
|
|
|
|
playHand = async (player: Player, placeBet: boolean) => {
|
|
let playerLoose = false;
|
|
let moreCards = true;
|
|
let betPlaced = false;
|
|
let score;
|
|
|
|
const prompt = player.prompt = new Prompt();
|
|
while (moreCards && !playerLoose) {
|
|
prompt.fields.splice(0, prompt.fields.length);
|
|
prompt.buttons.splice(0, prompt.buttons.length);
|
|
if (placeBet && !betPlaced) {
|
|
prompt.fields.push(new PromptField({
|
|
name: 'bet',
|
|
label: 'Quanto scommetti',
|
|
min: this.state.minimumBet,
|
|
max: Math.min(this.state.pot, player.stash),
|
|
value: this.state.minimumBet
|
|
}));
|
|
}
|
|
|
|
prompt.buttons.push(new PromptButton({
|
|
name: 'stand',
|
|
label: 'Sto bene',
|
|
type: 'primary'
|
|
}));
|
|
|
|
prompt.buttons.push(new PromptButton({
|
|
name: 'more',
|
|
label: 'Carta',
|
|
type: 'primary'
|
|
}));
|
|
|
|
// se é un quattro servito, offri la possibilitá di "bruciare"
|
|
const lucio = player.hand.find((c) => { return c.card.card.value === 3 });
|
|
if (player.hand.length == 1 && lucio !== undefined) {
|
|
console.log('LUCIO:', lucio, lucio.card);
|
|
prompt.buttons.push(new PromptButton({
|
|
name: 'discard',
|
|
label: 'Brucia!',
|
|
type: 'primary'
|
|
}));
|
|
}
|
|
|
|
prompt.visible = true;
|
|
|
|
const choice = await this.getPlayerAction(player);
|
|
console.log('Player choice was:', choice);
|
|
|
|
if (choice.action === 'discard') {
|
|
// mostra la carta
|
|
this.publishCards(player);
|
|
this.broadcast('notification', {
|
|
type: 'info',
|
|
text: `${player.displayName} brucia il quattro`
|
|
});
|
|
|
|
// aspetta un paio di secondi
|
|
await this.sleep(2000);
|
|
|
|
// dunque svuota la mano e serve un'altra carta coperta:
|
|
player.hand.pop();
|
|
this.dealCard(player, false);
|
|
} else {
|
|
moreCards = false;
|
|
}
|
|
|
|
if (choice.hasOwnProperty('bet') && choice.action !== 'discard') {
|
|
player.stash -= choice.bet;
|
|
this.state.currentBet += choice.bet;
|
|
betPlaced = true;
|
|
}
|
|
|
|
if (choice.action === 'more') {
|
|
this.dealCard(player, true);
|
|
moreCards = true;
|
|
}
|
|
|
|
// il giocatore ha sballato? -> perde e passa il turno
|
|
score = this.calculateScore(player.hand);
|
|
console.log('Score:', score);
|
|
player.score = score.score;
|
|
|
|
if (score.score > 7.5) {
|
|
console.log('Hai sballato fraté!');
|
|
playerLoose = true;
|
|
this.publishCards(player);
|
|
this.broadcast('notification', {
|
|
type: 'error',
|
|
text: `${player.displayName} ha sballato`
|
|
});
|
|
player.prompt.visible = false;
|
|
await this.sleep(3000);
|
|
} else if (score.score == 7.5) {
|
|
console.log('Sette e mezzo!!!!');
|
|
this.publishCards(player);
|
|
moreCards = false;
|
|
player.prompt.visible = false;
|
|
}
|
|
}
|
|
prompt.visible = false;
|
|
return { score, playerLoose };
|
|
}
|
|
|
|
pauseGameIfPlayersDontMeetRequirements = async (): Promise<void> => {
|
|
const poorPlayers = this.getSeatedPlayers().filter((player) => {
|
|
return player.stash < this.state.minimumBet * 2;
|
|
});
|
|
|
|
if (poorPlayers.length > 0) {
|
|
console.log('Pausing game since not all players meet requirements');
|
|
|
|
this.state.paused = true;
|
|
poorPlayers.forEach((player) => {
|
|
this.broadcast('notification', {
|
|
type: 'warning',
|
|
text: `${player.displayName} non ha abbastanza soldi per giocare`
|
|
});
|
|
});
|
|
|
|
return new Promise<void>(resolve => {
|
|
const unbind = this.messageHandlers.on('resume-game', (client, message) => {
|
|
console.log('RESUME GAME?');
|
|
const poorPlayers = this.getSeatedPlayers().filter((player) => {
|
|
return player.stash < this.state.minimumBet * 2;
|
|
});
|
|
|
|
if (poorPlayers.length > 0) {
|
|
this.broadcast('notification', {
|
|
type: 'warning',
|
|
text: `Tutti i giocatori al tavolo devono avere almeno ${this.state.minimumBet * 2} per riprendere la partita`
|
|
});
|
|
console.log('not resuming');
|
|
return;
|
|
}
|
|
|
|
console.log('resuming');
|
|
this.state.paused = false;
|
|
|
|
unbind();
|
|
resolve();
|
|
});
|
|
});
|
|
} else {
|
|
return Promise.resolve();
|
|
}
|
|
}
|
|
|
|
async onStartGame() {
|
|
console.log('Game started');
|
|
|
|
while (this.state.running) {
|
|
await this.pauseGameIfPlayersDontMeetRequirements();
|
|
|
|
// Letting in players waiting
|
|
this.pMap((player) => {
|
|
if (player.enteringNextTurn) {
|
|
const seat = this.nextAvailableSeat();
|
|
console.log('assigning seat to user:', player.displayName, seat);
|
|
player.enteringNextTurn = false;
|
|
player.seat = seat;
|
|
player.playing = true;
|
|
|
|
};
|
|
return player;
|
|
})
|
|
|
|
// collect the initial bets
|
|
this.getSeatedPlayers().map((player) => {
|
|
player.stash -= this.state.minimumBet;
|
|
this.state.pot += this.state.minimumBet;
|
|
return player;
|
|
});
|
|
|
|
// shuffle cards
|
|
this.shuffleCards();
|
|
this.broadcast('notification', {
|
|
type: 'info',
|
|
text: 'Le carte sono state mischiate perché il mazzo passa ad un nuovo mazziere'
|
|
});
|
|
|
|
// set the dealer
|
|
const dealerSeat = this.nextDealer();
|
|
this.state.player1 = this.getPlayerBySeat(dealerSeat);
|
|
console.log('Dealer player is:', this.state.player1.displayName);
|
|
this.broadcast('notification', {
|
|
type: 'info',
|
|
text: `${this.state.player1.displayName} é il nuovo banco`
|
|
});
|
|
// await this.sleep(3000);
|
|
|
|
const table = this.nextPlayerGenerator(this.state.player1);
|
|
|
|
while (this.state.pot > 0) {
|
|
const p: Player = <Player>table.next().value;
|
|
if (p === null) {
|
|
throw Error('nextPlayerGenerator didn\'t return a valid Player instance');
|
|
}
|
|
|
|
if (p === this.state.player1) {
|
|
const prompt = this.state.player1.prompt = new Prompt();
|
|
|
|
prompt.buttons.push(new PromptButton({
|
|
name: 'cash-out',
|
|
label: 'Prendi il piatto',
|
|
type: 'primary'
|
|
}));
|
|
|
|
prompt.buttons.push(new PromptButton({
|
|
name: 'another-round',
|
|
label: 'Ancora un altro giro',
|
|
type: 'secondary'
|
|
}));
|
|
prompt.visible = true;
|
|
const choice = await this.getPlayerAction(this.state.player1);
|
|
this.state.player1.prompt = new Prompt();
|
|
console.log('Player choice was:', choice);
|
|
|
|
if (choice.action === 'cash-out') {
|
|
this.broadcast('notification', {
|
|
type: 'info',
|
|
text: `Il banco (${this.state.player1.displayName}) prende ${this.state.pot} del piatto e passa la mano`
|
|
});
|
|
this.state.player1.stash += this.state.pot;
|
|
this.state.pot = 0;
|
|
|
|
await this.sleep(3000);
|
|
break;
|
|
} else {
|
|
this.broadcast('notification', {
|
|
type: 'info',
|
|
text: `Il banco (${this.state.player1.displayName}) ha deciso per un altro giro`
|
|
});
|
|
await this.sleep(3000);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
await this.pauseGameIfPlayersDontMeetRequirements();
|
|
|
|
this.state.player2 = p;
|
|
console.log('Challenger player is:', this.state.player2.displayName);
|
|
|
|
// Deal draft cards
|
|
this.dealCard(this.state.player2, false);
|
|
this.state.player2.score = this.calculateScore(this.state.player2.hand).score;
|
|
|
|
this.dealCard(this.state.player1, false);
|
|
this.state.player1.score = this.calculateScore(this.state.player1.hand).score
|
|
|
|
// Let challenger play his hand
|
|
let challengerWins = false;
|
|
|
|
const challengerResult = await this.playHand(this.state.player2, true);
|
|
console.log('Challenger:', challengerResult);;
|
|
|
|
this.publishCards(this.state.player1);
|
|
if (!challengerResult.playerLoose) {
|
|
const dealerResult = await this.playHand(this.state.player1, false);
|
|
console.log('Dealer:', dealerResult);
|
|
|
|
this.publishCards(this.state.player2);
|
|
if (!dealerResult.playerLoose) {
|
|
challengerWins = challengerResult.score.score > dealerResult.score.score;
|
|
} else {
|
|
challengerWins = true;
|
|
}
|
|
} else {
|
|
challengerWins = false;
|
|
console.log('Dealer wins!');
|
|
}
|
|
|
|
await this.sleep(5000);
|
|
|
|
const jollyWasDealt = this.hasJolly(this.state.player1.hand) || this.hasJolly(this.state.player2.hand);
|
|
console.log('La matta é stata giocata? ', jollyWasDealt);
|
|
|
|
if (challengerWins) {
|
|
this.broadcast('notification', {
|
|
type: 'success',
|
|
text: `${this.state.player2.displayName} vince (${this.state.currentBet}) dal piatto`
|
|
});
|
|
this.state.player2.stash += this.state.currentBet * 2;
|
|
this.state.pot -= this.state.currentBet;
|
|
assert(this.state.pot >= 0);
|
|
await this.sleep(3000);
|
|
} else {
|
|
this.broadcast('notification', {
|
|
type: 'success',
|
|
text: `Il banco (${this.state.player1.displayName}) vince la mano`
|
|
});
|
|
this.state.pot += this.state.currentBet;
|
|
}
|
|
this.state.currentBet = 0;
|
|
|
|
// svuota le mani
|
|
this.tossAllHands();
|
|
|
|
if (jollyWasDealt) {
|
|
console.log('Shuffling deck since jolly was dealt in last hand');
|
|
this.shuffleCards();
|
|
|
|
this.broadcast('notification', {
|
|
type: 'info',
|
|
text: 'Le carte sono state mischiate perché la matta é stata estratta nell\'ultima mano'
|
|
});
|
|
await this.sleep(3000);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
onAuth(client, options, request) {
|
|
console.log('onauth', options, request.session.user_id);
|
|
const user = this.validateSession(request.session.user_id);
|
|
console.log('Found user:', user);
|
|
return user;
|
|
}
|
|
|
|
onCreate(options) {
|
|
this.deck = this.gameType.createDeck();
|
|
this.deck.shuffle();
|
|
console.log('Deck created:', this.deck);
|
|
|
|
console.log('GameRoom instance created', this.roomId, options);
|
|
this.validateSession = options.validateSession;
|
|
|
|
this.setState(new GameState());
|
|
this.state.roomOwner = options.user_id;
|
|
|
|
this.onMessage('admin', (client, message) => this.onAdminCommand(client, message));
|
|
this.onMessage('webrtc', (client, message) => this.onWebRTCCommand(client, message));
|
|
this.onMessage('*', (client, type: string, message) => {
|
|
console.log('Relaying message:', type, message);
|
|
this.messageHandlers.emit(type, client, message);
|
|
});
|
|
|
|
this.clock.start();
|
|
|
|
// DEMO: rotates the background every 60 seconds
|
|
this.clock.setInterval(() => {
|
|
this.state.bg = 1 + (this.state.bg + 1) % 5;
|
|
}, 60000);
|
|
}
|
|
|
|
onJoin(client, options, user) {
|
|
console.log(`User ${user.display_name} (${user.user_id}) joined room ${this.roomId}`, options);
|
|
|
|
if (this.state.players.size === 0) {
|
|
console.log(`User ${user.display_name} (${user.user_id}) set as room ${this.roomId} owner`);
|
|
this.state.roomOwner = user.user_id;
|
|
}
|
|
|
|
const user_already_logged_in = Array.from(this.state.players.values()).find((item) => item.id === user.user_id);
|
|
if (user_already_logged_in) {
|
|
console.log('User was already in the game!', user_already_logged_in);
|
|
return;
|
|
}
|
|
|
|
this.state.players.set(client.sessionId, new Player({
|
|
id: user.user_id,
|
|
sessionId: client.sessionId,
|
|
displayName: user.display_name,
|
|
owner: user.user_id === this.state.roomOwner,
|
|
stash: 0,
|
|
connected: true,
|
|
playing: false,
|
|
dealer: false,
|
|
hand: []
|
|
}));
|
|
}
|
|
|
|
async onLeave(client, consented) {
|
|
console.log('Player leaving:', client.sessionId);
|
|
if (this.state.players.has(client.sessionId)) {
|
|
this.state.players[client.sessionId].connected = false;
|
|
}
|
|
|
|
try {
|
|
if (consented) {
|
|
throw new Error("consented leave");
|
|
}
|
|
|
|
// allow disconnected client to reconnect into this room until 60 seconds
|
|
await this.allowReconnection(client, 60);
|
|
|
|
// client returned! let's re-activate it.
|
|
this.state.players[client.sessionId].connected = true;
|
|
|
|
} catch (e) {
|
|
// 60 seconds expired. let's remove the client.
|
|
this.state.players.delete(client.sessionId);
|
|
|
|
// explicitly broadcasting the event to let WebRTC cleanup corectly
|
|
this.broadcast('player-left', client.sessionId);
|
|
}
|
|
}
|
|
|
|
onAdminCommand(client, message) {
|
|
console.log('Admin command received:', message);
|
|
const player = this.state.players.get(client.sessionId);
|
|
if (player.id !== this.state.roomOwner) {
|
|
console.warn('Received admin command from non-owner user:', client.sessionId, message);
|
|
return;
|
|
}
|
|
|
|
const admin_commands = {
|
|
// set user stash (session_id, amount)
|
|
'set-user-stash': SetUserStashCommand,
|
|
|
|
// add to user stash (session_id, amount)
|
|
'add-to-user-stash': AddToUserStashCommand,
|
|
|
|
// remove from user stash (session_id, amount)
|
|
'remove-from-user-stash': RemoveFromUserStashCommand,
|
|
|
|
// assign seat to player (session_id)
|
|
'assign-seat': AssignSeatCommand,
|
|
|
|
// demote player (session_id)
|
|
// shuffle seats
|
|
|
|
// give room ownership (session_id)
|
|
'give-room-ownership': GiveRoomOwnershipCommand,
|
|
|
|
// start game
|
|
'start-game': StartGameCommand,
|
|
|
|
// pause game
|
|
'pause-game': PauseGameCommand,
|
|
|
|
// set room name (new name)
|
|
// set room background (background)
|
|
};
|
|
|
|
if (!admin_commands.hasOwnProperty(message.command)) {
|
|
console.error("Invalid admin command", message);
|
|
return;
|
|
}
|
|
|
|
const commandClass = admin_commands[message.command];
|
|
this.dispatcher.dispatch(new commandClass(), { ...message.payload, client });
|
|
}
|
|
|
|
onWebRTCCommand(client, message) {
|
|
console.log('WebRTC command received:', message);
|
|
const player = this.clients.find((value) => { return value.sessionId === message.payload.targetId });
|
|
if (player === null) {
|
|
console.warn('Cannot find client with sessionId', message.payload.targetId);
|
|
return;
|
|
}
|
|
|
|
if (message.command === 'call-player') {
|
|
console.log('Relaying WebRTC call:', message.payload);
|
|
if (player === undefined) {
|
|
console.warn('Incoming call to an unknown player');
|
|
return;
|
|
}
|
|
player.send('incoming-call', message.payload);
|
|
}
|
|
|
|
if (message.command === 'answer-call') {
|
|
console.log('Relaying WebRTC call:', message.payload);
|
|
player.send('answer-call', message.payload);
|
|
}
|
|
}
|
|
}
|