inital commit
This commit is contained in:
commit
42517c0fb5
18 changed files with 3794 additions and 0 deletions
2
.env
Normal file
2
.env
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
PORT=3001
|
||||||
|
ENVIRONMENT=local
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
dist
|
||||||
|
|
||||||
|
src/logs/
|
||||||
2824
package-lock.json
generated
Normal file
2824
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
52
package.json
Normal file
52
package.json
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"name": "skyjo-be",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Skyjo Backend",
|
||||||
|
"main": "server.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"dev": "ts-node src/server.ts",
|
||||||
|
"build": "prisma generate && tsc",
|
||||||
|
"start": "node dist/server.js"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/pb-coding/skyjo-be.git"
|
||||||
|
},
|
||||||
|
"author": "pb-coding",
|
||||||
|
"license": "ISC",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/pb-coding/skyjo-be/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/pb-coding/skyjo-be#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.4.0",
|
||||||
|
"bcrypt": "^5.1.0",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-validator": "^7.0.1",
|
||||||
|
"jsonwebtoken": "^9.0.1",
|
||||||
|
"node-cron": "^3.0.2",
|
||||||
|
"request": "^2.88.2",
|
||||||
|
"socket.io": "^4.7.2",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.0",
|
||||||
|
"@types/cookie-parser": "^1.4.3",
|
||||||
|
"@types/cors": "^2.8.13",
|
||||||
|
"@types/dotenv": "^8.2.0",
|
||||||
|
"@types/express": "^4.17.17",
|
||||||
|
"@types/jsonwebtoken": "^9.0.2",
|
||||||
|
"@types/node": "^20.4.2",
|
||||||
|
"@types/node-cron": "^3.0.8",
|
||||||
|
"@types/request": "^2.48.8",
|
||||||
|
"@types/uuid": "^9.0.2",
|
||||||
|
"esbuild-register": "^3.4.2",
|
||||||
|
"typescript": "^5.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
public/index.html
Normal file
10
public/index.html
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>SkyJo Server list</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>SkyJo Server list</h1>
|
||||||
|
<button>Start new Game</button>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
src/config/allowedOrigins.ts
Normal file
3
src/config/allowedOrigins.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
const allowedOrigins = ["http://localhost:3000", "https://localhost:3001"];
|
||||||
|
|
||||||
|
export default allowedOrigins;
|
||||||
17
src/config/corsOptions.ts
Normal file
17
src/config/corsOptions.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import allowedOrigins from "./allowedOrigins";
|
||||||
|
|
||||||
|
const corsOptions = {
|
||||||
|
origin: (
|
||||||
|
origin: string | undefined,
|
||||||
|
callback: (err: Error | null, allow?: boolean) => void
|
||||||
|
) => {
|
||||||
|
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
|
||||||
|
callback(null, true);
|
||||||
|
} else {
|
||||||
|
callback(new Error("Not allowed by CORS"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optionsSuccessStatus: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default corsOptions;
|
||||||
170
src/game/card.ts
Normal file
170
src/game/card.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
type CardValue = -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||||
|
|
||||||
|
export type ObfuscatedCardValue = CardValue | "X";
|
||||||
|
|
||||||
|
type CardColor =
|
||||||
|
| "darkblue"
|
||||||
|
| "lightblue"
|
||||||
|
| "green"
|
||||||
|
| "yellow"
|
||||||
|
| "red"
|
||||||
|
| "black";
|
||||||
|
|
||||||
|
// TODO: use smarter types like Omit<>, Pick<>, etc.
|
||||||
|
export type ObfuscatedCard = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
value: ObfuscatedCardValue;
|
||||||
|
color: CardColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Card {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
value: CardValue;
|
||||||
|
color: CardColor;
|
||||||
|
constructor(id: number, value: CardValue) {
|
||||||
|
this.id = id;
|
||||||
|
this.value = value;
|
||||||
|
this.name = `${value} Card`;
|
||||||
|
this.color = this.matchColorToCardValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
matchColorToCardValue(value: CardValue | ObfuscatedCardValue): CardColor {
|
||||||
|
switch (value) {
|
||||||
|
case -2:
|
||||||
|
return "darkblue";
|
||||||
|
case -1:
|
||||||
|
return "darkblue";
|
||||||
|
case 0:
|
||||||
|
return "lightblue";
|
||||||
|
case 1:
|
||||||
|
return "green";
|
||||||
|
case 2:
|
||||||
|
return "green";
|
||||||
|
case 3:
|
||||||
|
return "green";
|
||||||
|
case 4:
|
||||||
|
return "green";
|
||||||
|
case 5:
|
||||||
|
return "yellow";
|
||||||
|
case 6:
|
||||||
|
return "yellow";
|
||||||
|
case 7:
|
||||||
|
return "yellow";
|
||||||
|
case 8:
|
||||||
|
return "yellow";
|
||||||
|
case 9:
|
||||||
|
return "red";
|
||||||
|
case 10:
|
||||||
|
return "red";
|
||||||
|
case 11:
|
||||||
|
return "red";
|
||||||
|
case 12:
|
||||||
|
return "red";
|
||||||
|
case "X":
|
||||||
|
return "black";
|
||||||
|
default:
|
||||||
|
return "red";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObfuscatedCardStack = {
|
||||||
|
cards: ObfuscatedCard[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CardStack {
|
||||||
|
cards: Card[];
|
||||||
|
constructor() {
|
||||||
|
this.cards = [];
|
||||||
|
this.generateCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
generateCards() {
|
||||||
|
for (let cardNumber = 1; cardNumber <= 150; cardNumber++) {
|
||||||
|
if (cardNumber <= 5) {
|
||||||
|
this.cards.push(new Card(cardNumber, -2));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 5 && cardNumber <= 15) {
|
||||||
|
this.cards.push(new Card(cardNumber, -1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 15 && cardNumber <= 30) {
|
||||||
|
this.cards.push(new Card(cardNumber, 0));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 30 && cardNumber <= 40) {
|
||||||
|
this.cards.push(new Card(cardNumber, 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 40 && cardNumber <= 50) {
|
||||||
|
this.cards.push(new Card(cardNumber, 2));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 50 && cardNumber <= 60) {
|
||||||
|
this.cards.push(new Card(cardNumber, 3));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 60 && cardNumber <= 70) {
|
||||||
|
this.cards.push(new Card(cardNumber, 4));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 70 && cardNumber <= 80) {
|
||||||
|
this.cards.push(new Card(cardNumber, 5));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 80 && cardNumber <= 90) {
|
||||||
|
this.cards.push(new Card(cardNumber, 6));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 90 && cardNumber <= 100) {
|
||||||
|
this.cards.push(new Card(cardNumber, 7));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 100 && cardNumber <= 110) {
|
||||||
|
this.cards.push(new Card(cardNumber, 8));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 110 && cardNumber <= 120) {
|
||||||
|
this.cards.push(new Card(cardNumber, 9));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 120 && cardNumber <= 130) {
|
||||||
|
this.cards.push(new Card(cardNumber, 10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 130 && cardNumber <= 140) {
|
||||||
|
this.cards.push(new Card(cardNumber, 11));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardNumber > 140 && cardNumber <= 150) {
|
||||||
|
this.cards.push(new Card(cardNumber, 12));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fisher-Yates shuffle algorithm
|
||||||
|
shuffleCards() {
|
||||||
|
for (let i = this.cards.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[this.cards[i], this.cards[j]] = [this.cards[j], this.cards[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/game/events.ts
Normal file
52
src/game/events.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { io } from "../server";
|
||||||
|
import { Game } from "./game";
|
||||||
|
import { Player, ObfuscatedPlayer } from "./player";
|
||||||
|
import { Card } from "./card";
|
||||||
|
|
||||||
|
export const handleJoinSession = (socket: Socket, sessionId: string) => {
|
||||||
|
socket.join(sessionId);
|
||||||
|
console.log("User joined session:", sessionId);
|
||||||
|
|
||||||
|
const numberOfClients = io.sockets.adapter.rooms.get(sessionId)?.size ?? 0;
|
||||||
|
console.log("Clients in room:", numberOfClients);
|
||||||
|
io.to(sessionId).emit("clients-in-session", numberOfClients);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleNewGame = (
|
||||||
|
socket: Socket,
|
||||||
|
gameDetails: { sessionId: string }
|
||||||
|
) => {
|
||||||
|
console.log("Received game details:", gameDetails);
|
||||||
|
const { sessionId } = gameDetails;
|
||||||
|
|
||||||
|
const players = io.sockets.adapter.rooms.get(sessionId);
|
||||||
|
|
||||||
|
if (players && players.size > 1) {
|
||||||
|
const game = new Game(socket, sessionId, players);
|
||||||
|
game.sendObfuscatedGameUpdate();
|
||||||
|
} else {
|
||||||
|
// error handling
|
||||||
|
console.log("Not enough players to start a game");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const obfuscatePlayerCards = (player: Player): ObfuscatedPlayer => {
|
||||||
|
return {
|
||||||
|
id: player.id,
|
||||||
|
socketId: player.socketId,
|
||||||
|
name: player.name,
|
||||||
|
cards: player.cards.map((card: Card, index: number) => {
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
value: player.knownCardPositions[index] ? card.value : 0, // unknown cards are obfuscated to 0
|
||||||
|
name: card.name,
|
||||||
|
color: card.color,
|
||||||
|
matchColorToCardValue: card.matchColorToCardValue,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
knownCardPositions: player.knownCardPositions,
|
||||||
|
playersTurn: player.playersTurn,
|
||||||
|
cardCache: player.cardCache,
|
||||||
|
};
|
||||||
|
};
|
||||||
378
src/game/game.ts
Normal file
378
src/game/game.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
import { Player, ObfuscatedPlayer } from "./player";
|
||||||
|
import { CardStack } from "./card";
|
||||||
|
import { Card, ObfuscatedCardStack } from "./card";
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { io } from "../server";
|
||||||
|
|
||||||
|
type PlayerSocketSet = Set<string>;
|
||||||
|
|
||||||
|
type PlayerActions = Array<
|
||||||
|
[string, (playerSocketId: string, data: any) => void]
|
||||||
|
>;
|
||||||
|
|
||||||
|
// obfuscated types are used to send only necessary data to the client
|
||||||
|
export type ObfuscatedGame = {
|
||||||
|
sessionId: string;
|
||||||
|
playerCount: number;
|
||||||
|
players: ObfuscatedPlayer[];
|
||||||
|
cardStack: ObfuscatedCardStack;
|
||||||
|
discardPile: Card[];
|
||||||
|
phase: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gamePhase = {
|
||||||
|
revealTwoCards: "reveal two cards",
|
||||||
|
pickUpCard: "pick up card",
|
||||||
|
placeCard: "place card",
|
||||||
|
revealCard: "reveal card",
|
||||||
|
revealedLastCard: "revealed last card",
|
||||||
|
gameEnded: "game ended",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Game {
|
||||||
|
socket: Socket;
|
||||||
|
sessionId: string;
|
||||||
|
playerCount: number;
|
||||||
|
players: Player[];
|
||||||
|
cardStack: CardStack;
|
||||||
|
discardPile: Card[];
|
||||||
|
phase: string;
|
||||||
|
|
||||||
|
constructor(socket: Socket, sessionId: string, playerIds: PlayerSocketSet) {
|
||||||
|
this.socket = socket;
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
|
||||||
|
this.cardStack = new CardStack();
|
||||||
|
this.cardStack.shuffleCards();
|
||||||
|
|
||||||
|
this.playerCount = playerIds.size;
|
||||||
|
this.players = this.initializePlayers(playerIds, this.cardStack);
|
||||||
|
|
||||||
|
// get the first card from the cardStack and put it in the discard pile
|
||||||
|
this.discardPile = [this.cardStack.cards.pop()!];
|
||||||
|
this.phase = gamePhase.revealTwoCards;
|
||||||
|
|
||||||
|
console.log(`New Game created with ${this.playerCount} players!`);
|
||||||
|
this.gameLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializePlayers(
|
||||||
|
playerIds: PlayerSocketSet,
|
||||||
|
cardStack: CardStack
|
||||||
|
): Player[] {
|
||||||
|
let players: Player[] = [];
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
playerIds.forEach((socketId) => {
|
||||||
|
index++;
|
||||||
|
const playerCards = cardStack.cards.splice(0, 12);
|
||||||
|
const player = new Player(
|
||||||
|
index,
|
||||||
|
socketId,
|
||||||
|
`Player ${index}`,
|
||||||
|
playerCards
|
||||||
|
);
|
||||||
|
players.push(player);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cardStack = cardStack;
|
||||||
|
|
||||||
|
return players;
|
||||||
|
}
|
||||||
|
|
||||||
|
async gameLoop() {
|
||||||
|
console.log("Game started!");
|
||||||
|
while (this.phase !== gamePhase.gameEnded) {
|
||||||
|
switch (this.phase) {
|
||||||
|
case gamePhase.revealTwoCards:
|
||||||
|
console.log("Game phase: revealTwoCards");
|
||||||
|
this.listPlayerSocketListeners();
|
||||||
|
await this.revealInitialCards();
|
||||||
|
this.phase = gamePhase.pickUpCard;
|
||||||
|
break;
|
||||||
|
case gamePhase.pickUpCard:
|
||||||
|
console.log("Game phase: pickUpCard");
|
||||||
|
this.listPlayerSocketListeners();
|
||||||
|
await this.pickUpCard();
|
||||||
|
this.phase = gamePhase.placeCard;
|
||||||
|
break;
|
||||||
|
case gamePhase.placeCard:
|
||||||
|
console.log("Game phase: placeCard");
|
||||||
|
this.listPlayerSocketListeners();
|
||||||
|
await this.placeCard();
|
||||||
|
break;
|
||||||
|
case gamePhase.revealCard:
|
||||||
|
console.log("Game phase: revealCard");
|
||||||
|
this.listPlayerSocketListeners();
|
||||||
|
await this.revealCard();
|
||||||
|
this.phase = gamePhase.pickUpCard;
|
||||||
|
case gamePhase.revealedLastCard:
|
||||||
|
console.log("Game phase: revealedLastCard");
|
||||||
|
// add logic of last round
|
||||||
|
this.phase = gamePhase.gameEnded;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Invalid game phase: ${this.phase}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayersWithRevealedInitialCards(): Player[] {
|
||||||
|
return this.players.filter((player) => {
|
||||||
|
return player.hasInitialCardsRevealed();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
allPlayersRevealedInitialCards() {
|
||||||
|
const playersWithRevealedInitialCards =
|
||||||
|
this.getPlayersWithRevealedInitialCards();
|
||||||
|
return playersWithRevealedInitialCards.length === this.playerCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitialPlayersTurn() {
|
||||||
|
const playersWithHighestRevealedCardsValueSum = this.players.reduce(
|
||||||
|
(playersWithHighestRevealedCardsValueSum, player) => {
|
||||||
|
if (
|
||||||
|
player.getRevealedCardsValueSum() ===
|
||||||
|
playersWithHighestRevealedCardsValueSum[0].getRevealedCardsValueSum()
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
player.getHighestRevealedCardValue() >
|
||||||
|
playersWithHighestRevealedCardsValueSum[0].getHighestRevealedCardValue()
|
||||||
|
) {
|
||||||
|
playersWithHighestRevealedCardsValueSum = [player];
|
||||||
|
} else if (
|
||||||
|
player.getHighestRevealedCardValue() ===
|
||||||
|
playersWithHighestRevealedCardsValueSum[0].getHighestRevealedCardValue()
|
||||||
|
) {
|
||||||
|
playersWithHighestRevealedCardsValueSum.push(player);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
player.getRevealedCardsValueSum() >
|
||||||
|
playersWithHighestRevealedCardsValueSum[0].getRevealedCardsValueSum()
|
||||||
|
) {
|
||||||
|
playersWithHighestRevealedCardsValueSum = [player];
|
||||||
|
}
|
||||||
|
return playersWithHighestRevealedCardsValueSum;
|
||||||
|
},
|
||||||
|
[this.players[0]]
|
||||||
|
);
|
||||||
|
|
||||||
|
const playerWithHighestRevealedCardsValueSum =
|
||||||
|
playersWithHighestRevealedCardsValueSum[
|
||||||
|
Math.floor(
|
||||||
|
Math.random() * playersWithHighestRevealedCardsValueSum.length
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
this.players.forEach((player) => {
|
||||||
|
if (player === playerWithHighestRevealedCardsValueSum) {
|
||||||
|
player.playersTurn = true;
|
||||||
|
} else {
|
||||||
|
player.playersTurn = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPlayersTurn() {
|
||||||
|
const playersTurn = this.players.find((player) => player.playersTurn);
|
||||||
|
const playersTurnIndex = this.players.indexOf(playersTurn!);
|
||||||
|
const nextPlayersTurnIndex = (playersTurnIndex + 1) % this.playerCount;
|
||||||
|
this.players[playersTurnIndex].playersTurn = false;
|
||||||
|
this.players[nextPlayersTurnIndex].playersTurn = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayerBySocketId(playerSocketId: string): Player | undefined {
|
||||||
|
return this.players.find((player) => player.socketId === playerSocketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
revealCardAction(playerSocketId: string, cardPosition: number) {
|
||||||
|
const player = this.getPlayerBySocketId(playerSocketId)!; // TODO: handle player not found
|
||||||
|
const revealedCard = player.cards[cardPosition];
|
||||||
|
console.log(`Revealed card ${revealedCard} at position ${cardPosition}`);
|
||||||
|
const playerIndex = this.players.indexOf(player!);
|
||||||
|
this.players[playerIndex].knownCardPositions[cardPosition] = true;
|
||||||
|
this.sendObfuscatedGameUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async revealInitialCards() {
|
||||||
|
while (!this.allPlayersRevealedInitialCards()) {
|
||||||
|
const playersWithRevealedInitialCards =
|
||||||
|
this.getPlayersWithRevealedInitialCards();
|
||||||
|
const playersWithUnrevealedInitialCards = this.players.filter(
|
||||||
|
(player) => !playersWithRevealedInitialCards.includes(player)
|
||||||
|
);
|
||||||
|
const playersSocketIds = playersWithUnrevealedInitialCards.map(
|
||||||
|
(player) => player.socketId
|
||||||
|
);
|
||||||
|
await this.waitForPlayerActions(
|
||||||
|
[["click-card", this.revealCardAction.bind(this)]],
|
||||||
|
playersSocketIds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.setInitialPlayersTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCardAction(playerSocketId: string, data: any) {
|
||||||
|
const player = this.getPlayerBySocketId(playerSocketId)!; // TODO: handle player not found
|
||||||
|
console.log(`Player ${player.name} drawed a card.`);
|
||||||
|
const drawnCard = this.cardStack.cards.pop()!;
|
||||||
|
player.cardCache = drawnCard;
|
||||||
|
this.sendObfuscatedGameUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
takeDiscardPileAction(playerSocketId: string, data: any) {
|
||||||
|
const player = this.getPlayerBySocketId(playerSocketId)!; // TODO: handle player not found
|
||||||
|
console.log(`Player ${player.name} took the card from discard pile.`);
|
||||||
|
const discardPileCard = this.discardPile.pop()!;
|
||||||
|
player.cardCache = discardPileCard;
|
||||||
|
this.sendObfuscatedGameUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
discardCardToPileAction(playerSocketId: string, data: any) {
|
||||||
|
const player = this.getPlayerBySocketId(playerSocketId)!; // TODO: handle player not found
|
||||||
|
console.log(`Player ${player.name} discarded a card to the pile.`);
|
||||||
|
const discardedCard = player.cardCache!;
|
||||||
|
this.discardPile.push(discardedCard);
|
||||||
|
player.cardCache = null;
|
||||||
|
this.sendObfuscatedGameUpdate();
|
||||||
|
this.phase = gamePhase.revealCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
placeCardAction(playerSocketId: string, cardPosition: number) {
|
||||||
|
const player = this.getPlayerBySocketId(playerSocketId)!; // TODO: handle player not found
|
||||||
|
console.log(`Player ${player.name} placed a card.`);
|
||||||
|
const placedCard = player.cardCache!;
|
||||||
|
player.cardCache = null;
|
||||||
|
const replacedCard = player.cards[cardPosition];
|
||||||
|
this.discardPile.push(replacedCard);
|
||||||
|
player.cards[cardPosition] = placedCard;
|
||||||
|
player.knownCardPositions[cardPosition] = true;
|
||||||
|
this.nextPlayersTurn();
|
||||||
|
this.phase = gamePhase.pickUpCard;
|
||||||
|
this.sendObfuscatedGameUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async pickUpCard() {
|
||||||
|
const playersTurn = this.players.find((player) => player.playersTurn);
|
||||||
|
console.log(`Waiting for ${playersTurn?.name} to pick up card`);
|
||||||
|
await this.waitForPlayerActions(
|
||||||
|
[
|
||||||
|
["draw-from-card-stack", this.drawCardAction.bind(this)],
|
||||||
|
["click-discard-pile", this.takeDiscardPileAction.bind(this)],
|
||||||
|
],
|
||||||
|
[playersTurn!.socketId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async placeCard(tookDiscardPileCard: boolean = false) {
|
||||||
|
console.log("Waiting for player to place card");
|
||||||
|
const playersTurn = this.players.find((player) => player.playersTurn);
|
||||||
|
const allowedActions: PlayerActions = [
|
||||||
|
["click-card", this.placeCardAction.bind(this)],
|
||||||
|
];
|
||||||
|
if (!tookDiscardPileCard) {
|
||||||
|
allowedActions.push([
|
||||||
|
"click-discard-pile",
|
||||||
|
this.discardCardToPileAction.bind(this),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await this.waitForPlayerActions(allowedActions, [playersTurn!.socketId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revealCard() {
|
||||||
|
console.log("Waiting for player to reveal a card");
|
||||||
|
const playersTurn = this.players.find((player) => player.playersTurn);
|
||||||
|
await this.waitForPlayerActions(
|
||||||
|
[["click-card", this.revealCardAction.bind(this)]],
|
||||||
|
[playersTurn!.socketId]
|
||||||
|
);
|
||||||
|
this.nextPlayersTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForPlayerActions(
|
||||||
|
expectedActions: PlayerActions,
|
||||||
|
expectedFrom: Player["socketId"][]
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const eventListeners: Array<(playerSocketId: string, data: any) => void> =
|
||||||
|
[];
|
||||||
|
expectedFrom.forEach((playerSocketId) => {
|
||||||
|
const playerSocket = io.sockets.sockets.get(playerSocketId);
|
||||||
|
if (playerSocket) {
|
||||||
|
expectedActions.forEach((expectedAction) => {
|
||||||
|
const [actionName, processAction] = expectedAction;
|
||||||
|
const eventListener = (data: any) => {
|
||||||
|
console.log(`Received ${actionName} from ${playerSocketId}`);
|
||||||
|
processAction(playerSocketId, data);
|
||||||
|
// remove current and event listeners of alternative expected actions
|
||||||
|
eventListeners.forEach((eventListener) => {
|
||||||
|
playerSocket.off(actionName, eventListener);
|
||||||
|
});
|
||||||
|
resolve(data);
|
||||||
|
};
|
||||||
|
playerSocket.on(actionName, eventListener);
|
||||||
|
eventListeners.push(eventListener);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// TODO: handle error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendObfuscatedGameUpdate() {
|
||||||
|
// console.trace("sendObfuscatedGameUpdate");
|
||||||
|
const obfuscatedGame: ObfuscatedGame = {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
playerCount: this.playerCount,
|
||||||
|
players: this.players.map((player: Player) => {
|
||||||
|
return {
|
||||||
|
id: player.id,
|
||||||
|
socketId: player.socketId,
|
||||||
|
name: player.name,
|
||||||
|
cards: player.cards.map((card: Card, index: number) => {
|
||||||
|
// unknown cards are obfuscated to 0
|
||||||
|
return {
|
||||||
|
id: player.knownCardPositions[index] ? card.id : 0,
|
||||||
|
value: player.knownCardPositions[index] ? card.value : "X",
|
||||||
|
name: player.knownCardPositions[index]
|
||||||
|
? card.name
|
||||||
|
: "Facedown Card",
|
||||||
|
color: player.knownCardPositions[index] ? card.color : "black",
|
||||||
|
matchColorToCardValue: card.matchColorToCardValue,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
knownCardPositions: player.knownCardPositions,
|
||||||
|
playersTurn: player.playersTurn,
|
||||||
|
cardCache: player.cardCache,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
cardStack: {
|
||||||
|
cards: this.cardStack.cards.map((card: Card) => {
|
||||||
|
// player may not see the value of the facedown cards in the cardStack
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
value: "X",
|
||||||
|
name: "Facedown Card",
|
||||||
|
color: "black",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
discardPile: this.discardPile,
|
||||||
|
phase: this.phase,
|
||||||
|
};
|
||||||
|
console.log("Sending game update");
|
||||||
|
io.to(this.sessionId).emit("game-update", obfuscatedGame);
|
||||||
|
}
|
||||||
|
|
||||||
|
listPlayerSocketListeners() {
|
||||||
|
console.log("Player socket listeners:");
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
console.log(socket.id);
|
||||||
|
socket.eventNames().forEach((eventName) => {
|
||||||
|
console.log(`${eventName.toString()}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/game/player.ts
Normal file
79
src/game/player.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { Card, ObfuscatedCard } from "./card";
|
||||||
|
|
||||||
|
export type ObfuscatedPlayer = {
|
||||||
|
id: number;
|
||||||
|
socketId: string;
|
||||||
|
name: string;
|
||||||
|
cards: ObfuscatedCard[];
|
||||||
|
knownCardPositions: boolean[];
|
||||||
|
playersTurn: boolean;
|
||||||
|
cardCache: Card | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Player {
|
||||||
|
id: number;
|
||||||
|
socketId: string;
|
||||||
|
name: string;
|
||||||
|
cards: Card[];
|
||||||
|
knownCardPositions: boolean[];
|
||||||
|
playersTurn: boolean;
|
||||||
|
cardCache: Card | null; // this is where the card is temporarily stored when a player draws a card
|
||||||
|
constructor(id: number, socketId: string, name: string, cards: Card[]) {
|
||||||
|
this.id = id;
|
||||||
|
this.socketId = socketId;
|
||||||
|
this.name = name;
|
||||||
|
this.cards = cards;
|
||||||
|
this.knownCardPositions = new Array(12).fill(false);
|
||||||
|
this.playersTurn = true;
|
||||||
|
this.cardCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasInitialCardsRevealed(): boolean {
|
||||||
|
const revealedCards = this.knownCardPositions.filter(
|
||||||
|
(knownCard) => knownCard === true
|
||||||
|
);
|
||||||
|
if (revealedCards.length > 1) return true;
|
||||||
|
else return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRevealedCardCount(): number {
|
||||||
|
return this.knownCardPositions.filter((position) => position).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRevealedCardPositions(): number[] {
|
||||||
|
const revealedCardPositions: number[] = [];
|
||||||
|
this.knownCardPositions.forEach((position, index) => {
|
||||||
|
if (position) revealedCardPositions.push(index);
|
||||||
|
});
|
||||||
|
return revealedCardPositions;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRevealedCards(): Card[] {
|
||||||
|
const revealedCards: Card[] = [];
|
||||||
|
this.knownCardPositions.forEach((position, index) => {
|
||||||
|
if (position) revealedCards.push(this.cards[index]);
|
||||||
|
});
|
||||||
|
return revealedCards;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRevealedCardsValueSum(): number {
|
||||||
|
const revealedCards = this.getRevealedCards();
|
||||||
|
const revealedCardsValueSum = revealedCards.reduce(
|
||||||
|
(sum, card) => sum + card.value,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return revealedCardsValueSum;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHighestRevealedCardValue(): number {
|
||||||
|
const revealedCards = this.getRevealedCards();
|
||||||
|
const highestRevealedCardValue = revealedCards.reduce(
|
||||||
|
(highestValue, card) => {
|
||||||
|
if (card.value > highestValue) return card.value;
|
||||||
|
else return highestValue;
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return highestRevealedCardValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/lib/app.ts
Normal file
4
src/lib/app.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import express from "express";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
export default app;
|
||||||
12
src/middleware/credentials.ts
Normal file
12
src/middleware/credentials.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import allowedOrigins from "../config/allowedOrigins";
|
||||||
|
|
||||||
|
const credentials = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (allowedOrigins.includes(origin as string)) {
|
||||||
|
res.setHeader("Access-Control-Allow-Credentials", "true");
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default credentials;
|
||||||
35
src/middleware/errorHandler.ts
Normal file
35
src/middleware/errorHandler.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { logEvents } from "./logEvents";
|
||||||
|
|
||||||
|
import { log } from "./logEvents";
|
||||||
|
|
||||||
|
const errorHandler = (
|
||||||
|
error: unknown,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => {
|
||||||
|
// TODO: Handle loads of different errors
|
||||||
|
console.error(error);
|
||||||
|
let errorMessage = "An unknown error occurred!";
|
||||||
|
let statusCode = 500;
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
logEvents(`${error.name}: ${error.message}`, "errorLog.txt");
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Error Handler", errorMessage);
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const asyncHandler = (fn: any) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
return Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default errorHandler;
|
||||||
59
src/middleware/logEvents.ts
Normal file
59
src/middleware/logEvents.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import { promises as fsPromises } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export const logEvents = async (message: string, logName: string) => {
|
||||||
|
const dateTime = format(new Date(), "yyyy-MM-dd HH:mm:ss");
|
||||||
|
const logItem = `${dateTime}\t${uuid()}\t${message}\n`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(path.join(__dirname, "..", "logs"))) {
|
||||||
|
await fsPromises.mkdir(path.join(__dirname, "..", "logs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsPromises.appendFile(
|
||||||
|
path.join(__dirname, "..", "logs", logName),
|
||||||
|
logItem
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const log = async (
|
||||||
|
topic: string,
|
||||||
|
message: string,
|
||||||
|
userId?: number,
|
||||||
|
logName: string = "logs.txt"
|
||||||
|
) => {
|
||||||
|
const dateTime = format(new Date(), "yyyy-MM-dd HH:mm:ss");
|
||||||
|
let logItem = `[${dateTime}]: `;
|
||||||
|
logItem += `${topic}: ${message} `;
|
||||||
|
logItem += userId ? `[User: ${userId}] ` : "";
|
||||||
|
console.log(logItem);
|
||||||
|
logItem += "\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(path.join(__dirname, "..", "logs"))) {
|
||||||
|
await fsPromises.mkdir(path.join(__dirname, "..", "logs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsPromises.appendFile(
|
||||||
|
path.join(__dirname, "..", "logs", logName),
|
||||||
|
logItem
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logger = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
logEvents(`${req.method}\t${req.headers.origin}\t${req.url}`, "reqLog.txt");
|
||||||
|
console.log(`${req.method} ${req.path}`);
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
9
src/routes/root.ts
Normal file
9
src/routes/root.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
|
||||||
|
const rootRouter = Router();
|
||||||
|
|
||||||
|
rootRouter.get("/", (req: Request, res: Response) => {
|
||||||
|
res.send("Skyjo API");
|
||||||
|
});
|
||||||
|
|
||||||
|
export default rootRouter;
|
||||||
69
src/server.ts
Normal file
69
src/server.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// thanks to https://github.com/Apollon77/meross-cloud for the Meross Cloud API
|
||||||
|
|
||||||
|
import express, { Request, Response } from "express";
|
||||||
|
import { Server } from "http";
|
||||||
|
import { Server as SocketIOServer, Socket } from "socket.io";
|
||||||
|
import app from "./lib/app";
|
||||||
|
import * as path from "path";
|
||||||
|
import logger, { log } from "./middleware/logEvents";
|
||||||
|
import errorHandler from "./middleware/errorHandler";
|
||||||
|
import credentials from "./middleware/credentials";
|
||||||
|
import cors from "cors";
|
||||||
|
import corsOptions from "./config/corsOptions";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import rootRouter from "./routes/root";
|
||||||
|
import { handleJoinSession, handleNewGame } from "./game/events";
|
||||||
|
|
||||||
|
const httpServer = new Server(app);
|
||||||
|
export const io = new SocketIOServer(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: "http://localhost:5173",
|
||||||
|
methods: ["GET", "POST"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on("connection", (socket: Socket) => {
|
||||||
|
console.log("A user connected:", socket.id);
|
||||||
|
|
||||||
|
socket.on("join-session", (sessionId: string) =>
|
||||||
|
handleJoinSession(socket, sessionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.on("new-game", (gameDetails: { sessionId: string }) =>
|
||||||
|
handleNewGame(socket, gameDetails)
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
console.log("A user disconnected:", socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// helps to debug reading envs
|
||||||
|
const environment = process.env.ENVIRONMENT ?? "can not read envs";
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
app.use(logger);
|
||||||
|
|
||||||
|
app.use(credentials);
|
||||||
|
|
||||||
|
app.use(cors(corsOptions));
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, "..", "public")));
|
||||||
|
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
app.use("/", rootRouter);
|
||||||
|
|
||||||
|
app.all("*", (req: Request, res: Response) => {
|
||||||
|
res.status(404).send("Not Found");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
log("ExpressJS", `Server listening on ${PORT} - Environment: ${environment}`);
|
||||||
|
});
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2018",
|
||||||
|
"module": "commonjs",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue