580 lines
No EOL
16 KiB
TypeScript
580 lines
No EOL
16 KiB
TypeScript
import { UserId } from '@shared/database/mixin/user';
|
|
import { newUUID } from '@shared/utils/uuid';
|
|
import { FastifyInstance } from 'fastify';
|
|
import { Pong } from './game';
|
|
import { GameMove, GameUpdate, JoinRes, SSocket, TourInfo } from './socket';
|
|
import { isNullish, shuffle } from '@shared/utils';
|
|
import { PongGameId, PongGameOutcome } from '@shared/database/mixin/pong';
|
|
import { Tournament } from './tour';
|
|
|
|
type PUser = {
|
|
id: UserId;
|
|
currentGame: null | GameId;
|
|
socket: SSocket;
|
|
windowId: string;
|
|
updateInterval: NodeJS.Timeout;
|
|
killSelfInterval: NodeJS.Timeout;
|
|
lastSeen: number;
|
|
};
|
|
|
|
type GameId = PongGameId;
|
|
|
|
class StateI {
|
|
public static readonly UPDATE_INTERVAL_FRAMES: number = 60;
|
|
public static readonly KEEP_ALIVE_MS: number = 30 * 1000;
|
|
public static readonly START_TIMER_TOURNAMENT: number = 60 * 2 * 1000;
|
|
|
|
public users: Map<UserId, PUser> = new Map();
|
|
public queue: Set<UserId> = new Set();
|
|
public queueInterval: NodeJS.Timeout;
|
|
public tournamentInterval: NodeJS.Timeout;
|
|
public games: Map<GameId, Pong> = new Map();
|
|
public tournament: Tournament | null = null;
|
|
|
|
public constructor(public fastify: FastifyInstance) {
|
|
this.queueInterval = setInterval(() => this.queuerFunction());
|
|
this.tournamentInterval = setInterval(() =>
|
|
this.tournamentIntervalFunc(),
|
|
);
|
|
void this.queueInterval;
|
|
void this.tournamentInterval;
|
|
}
|
|
|
|
private static getGameUpdateData(id: GameId, g: Pong): GameUpdate {
|
|
return {
|
|
gameId: id,
|
|
left: {
|
|
id: g.userLeft,
|
|
score: g.score[0],
|
|
paddle: {
|
|
x: g.leftPaddle.x,
|
|
y: g.leftPaddle.y,
|
|
width: g.leftPaddle.width,
|
|
height: g.leftPaddle.height,
|
|
},
|
|
},
|
|
right: {
|
|
id: g.userRight,
|
|
score: g.score[1],
|
|
paddle: {
|
|
x: g.rightPaddle.x,
|
|
y: g.rightPaddle.y,
|
|
width: g.rightPaddle.width,
|
|
height: g.rightPaddle.height,
|
|
},
|
|
},
|
|
ball: { x: g.ball.x, y: g.ball.y, size: g.ball.size },
|
|
local: g.local,
|
|
};
|
|
}
|
|
|
|
public initGame(
|
|
g: Pong | null,
|
|
gameId: GameId,
|
|
id1: UserId,
|
|
id2: UserId,
|
|
): Pong | null {
|
|
const u1 = this.users.get(id1);
|
|
const u2 = this.users.get(id2);
|
|
|
|
if (isNullish(u1) || isNullish(u2)) return null;
|
|
|
|
this.fastify.log.info({
|
|
msg: 'init new game',
|
|
user1: u1.id,
|
|
user2: u2.id,
|
|
});
|
|
if (g === null) g = new Pong(u1.id, u2.id);
|
|
const iState: GameUpdate = StateI.getGameUpdateData(gameId, g);
|
|
|
|
u1.socket.emit('newGame', iState);
|
|
u2.socket.emit('newGame', iState);
|
|
g.rdy_timer = Date.now();
|
|
this.games.set(gameId, g);
|
|
|
|
u1.currentGame = gameId;
|
|
u2.currentGame = gameId;
|
|
|
|
g.gameUpdate = setInterval(() => {
|
|
g.tick();
|
|
if (
|
|
g.sendSig === false &&
|
|
g.ready_checks[0] === true &&
|
|
g.ready_checks[1] === true
|
|
) {
|
|
u1.socket.emit('rdyEnd');
|
|
u2.socket.emit('rdyEnd');
|
|
g.sendSig = true;
|
|
}
|
|
if (g.ready_checks[0] === true && g.ready_checks[1] === true) {
|
|
this.gameUpdate(gameId, u1.socket);
|
|
this.gameUpdate(gameId, u2.socket);
|
|
}
|
|
if (g.checkWinner() !== null) {
|
|
this.cleanupGame(gameId, g);
|
|
}
|
|
}, 1000 / StateI.UPDATE_INTERVAL_FRAMES);
|
|
return g;
|
|
}
|
|
|
|
private getHello(socket: SSocket) {
|
|
const user = this.users.get(socket.authUser.id);
|
|
if (isNullish(user)) return;
|
|
|
|
user.lastSeen = Date.now();
|
|
}
|
|
|
|
private registerForTournament(sock: SSocket, name: string | null) {
|
|
const user = this.users.get(sock.authUser.id);
|
|
if (isNullish(user)) return;
|
|
|
|
if (isNullish(this.tournament)) {
|
|
sock.emit('tournamentRegister', {
|
|
kind: 'failure',
|
|
msg: 'No tournament exists',
|
|
});
|
|
return;
|
|
}
|
|
if (this.tournament.state !== 'prestart') {
|
|
sock.emit('tournamentRegister', {
|
|
kind: 'failure',
|
|
msg: 'No tournament already started',
|
|
});
|
|
return;
|
|
}
|
|
const udb = this.fastify.db.getUser(user.id);
|
|
if (isNullish(udb)) {
|
|
sock.emit('tournamentRegister', {
|
|
kind: 'failure',
|
|
msg: 'User not found',
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.tournament.addUser(user.id, name ?? udb.name);
|
|
sock.emit('tournamentRegister', {
|
|
kind: 'success',
|
|
msg: 'Registered to Tournament',
|
|
});
|
|
return;
|
|
}
|
|
|
|
private unregisterForTournament(sock: SSocket) {
|
|
const user = this.users.get(sock.authUser.id);
|
|
if (isNullish(user)) return;
|
|
|
|
if (isNullish(this.tournament)) {
|
|
sock.emit('tournamentRegister', {
|
|
kind: 'failure',
|
|
msg: 'No tournament exists',
|
|
});
|
|
return;
|
|
}
|
|
if (this.tournament.state !== 'prestart') {
|
|
sock.emit('tournamentRegister', {
|
|
kind: 'failure',
|
|
msg: 'No tournament already started',
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.tournament.removeUser(user.id);
|
|
sock.emit('tournamentRegister', {
|
|
kind: 'success',
|
|
msg: 'Unregistered to Tournament',
|
|
});
|
|
return;
|
|
}
|
|
|
|
private createTournament(sock: SSocket) {
|
|
const user = this.users.get(sock.authUser.id);
|
|
if (isNullish(user)) return;
|
|
|
|
if (this.tournament !== null) {
|
|
sock.emit('tournamentCreateMsg', {
|
|
kind: 'failure',
|
|
msg: 'A tournament already exists',
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.tournament = new Tournament(user.id);
|
|
this.registerForTournament(sock, null);
|
|
this.tournament.startTimeout = setTimeout(
|
|
() => this.tournament?.start(),
|
|
StateI.START_TIMER_TOURNAMENT,
|
|
);
|
|
}
|
|
|
|
private cleanupTournament() {
|
|
if (this.tournament === null) return;
|
|
this.tournament = null;
|
|
this.fastify.log.info('Tournament has been ended');
|
|
}
|
|
|
|
private startTournament(sock: SSocket) {
|
|
if (isNullish(this.tournament)) return;
|
|
const user = this.users.get(sock.authUser.id);
|
|
if (isNullish(user)) return;
|
|
if (user.id !== this.tournament.owner) return;
|
|
|
|
clearInterval(this.tournament.startTimeout);
|
|
this.tournament.start();
|
|
}
|
|
|
|
public newPausedGame(suid1: string, suid2: string): GameId | undefined {
|
|
if (!this.fastify.db.getUser(suid1) || !this.fastify.db.getUser(suid2)) {
|
|
return undefined;
|
|
}
|
|
const uid1: UserId = suid1 as UserId;
|
|
const uid2: UserId = suid2 as UserId;
|
|
const g = new Pong(uid1, uid2);
|
|
g.rdy_timer = -1;
|
|
const gameId = newUUID() as unknown as GameId;
|
|
|
|
this.games.set(gameId, g);
|
|
this.fastify.log.info('new paused game \'' + gameId + '\'');
|
|
return gameId;
|
|
}
|
|
|
|
private tournamentIntervalFunc() {
|
|
const broadcastTourEnding = (msg: string) => {
|
|
this.users.forEach((u) => {
|
|
u.socket.emit('tourEnding', msg);
|
|
});
|
|
};
|
|
if (this.tournament) {
|
|
if (this.tournament.state === 'canceled') {
|
|
broadcastTourEnding('Tournament was canceled');
|
|
this.cleanupTournament();
|
|
}
|
|
else if (this.tournament.state === 'ended') {
|
|
broadcastTourEnding('Tournament is finished !');
|
|
this.cleanupTournament();
|
|
}
|
|
else if (this.tournament.state === 'playing') {
|
|
const currentgame = this.tournament.currentGame;
|
|
if (currentgame) {
|
|
const game = this.games.get(currentgame);
|
|
if (game) {
|
|
const gameData = StateI.getGameUpdateData(
|
|
currentgame,
|
|
game,
|
|
);
|
|
for (const user of this.tournament.users
|
|
.keys()
|
|
.map((id) => this.users.get(id))
|
|
.filter((v) => !isNullish(v))) {
|
|
user.socket.emit('gameUpdate', gameData);
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
this.fastify.log.warn('NO NEXT GAME ?');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private queuerFunction(): void {
|
|
const values = Array.from(this.queue.values());
|
|
shuffle(values);
|
|
while (values.length >= 2) {
|
|
const id1 = values.pop();
|
|
const id2 = values.pop();
|
|
|
|
if (isNullish(id1) || isNullish(id2)) {
|
|
continue;
|
|
}
|
|
|
|
const u1 = this.users.get(id1);
|
|
const u2 = this.users.get(id2);
|
|
|
|
if (isNullish(u1) || isNullish(u2)) {
|
|
continue;
|
|
}
|
|
this.queue.delete(id1);
|
|
this.queue.delete(id2);
|
|
|
|
const gameId = newUUID() as unknown as GameId;
|
|
this.initGame(null, gameId, u1.id, u2.id);
|
|
}
|
|
}
|
|
|
|
private newLocalGame(sock: SSocket) {
|
|
const user = this.users.get(sock.authUser.id);
|
|
if (!user) return;
|
|
|
|
const gameId = newUUID() as unknown as GameId;
|
|
const g = Pong.makeLocal(user.id);
|
|
const iState: GameUpdate = StateI.getGameUpdateData(gameId, g);
|
|
|
|
user.socket.emit('newGame', iState);
|
|
this.games.set(gameId, g);
|
|
|
|
user.currentGame = gameId;
|
|
// here we dont use this.initGame because we are in a local game...
|
|
g.gameUpdate = setInterval(() => {
|
|
g.tick();
|
|
this.gameUpdate(gameId, user.socket);
|
|
if (g.sendSig === false) {
|
|
user.socket.emit('rdyEnd');
|
|
g.sendSig = true;
|
|
}
|
|
if (g.checkWinner() !== null) {
|
|
this.cleanupGame(gameId, g);
|
|
}
|
|
}, 1000 / StateI.UPDATE_INTERVAL_FRAMES);
|
|
}
|
|
|
|
private gameUpdate(id: GameId, sock: SSocket) {
|
|
// does the game we want to update the client exists ?
|
|
if (!this.games.has(id)) return;
|
|
// is the client someone we know ?
|
|
if (!this.users.has(sock.authUser.id)) return;
|
|
// is the client associated with that game ?
|
|
if (this.users.get(sock.authUser.id)!.currentGame !== id) return;
|
|
sock.emit(
|
|
'gameUpdate',
|
|
StateI.getGameUpdateData(id, this.games.get(id)!),
|
|
);
|
|
}
|
|
|
|
private gameMove(socket: SSocket, u: GameMove) {
|
|
// do we know this user ?
|
|
if (!this.users.has(socket.authUser.id)) return;
|
|
const user = this.users.get(socket.authUser.id)!;
|
|
// does the user have a game and do we know such game ?
|
|
if (user.currentGame === null || !this.games.has(user.currentGame)) {
|
|
return;
|
|
}
|
|
const game = this.games.get(user.currentGame)!;
|
|
|
|
if (game.local) {
|
|
if (u.move !== null) {
|
|
game.movePaddle('left', u.move);
|
|
}
|
|
if (u.moveRight !== null) {
|
|
game.movePaddle('right', u.moveRight);
|
|
}
|
|
}
|
|
else if (u.move !== null) {
|
|
game.movePaddle(user.id, u.move);
|
|
}
|
|
game.updateLastSeen(user.id);
|
|
}
|
|
|
|
public checkKillSelf(sock: SSocket) {
|
|
const user = this.users.get(sock.authUser.id);
|
|
if (isNullish(user)) return;
|
|
|
|
if (Date.now() - user.lastSeen < StateI.KEEP_ALIVE_MS) return;
|
|
|
|
this.cleanupUser(sock);
|
|
}
|
|
|
|
private tryJoinGame(g_id : string, sock : SSocket) : JoinRes {
|
|
const game_id : PongGameId = g_id as PongGameId;
|
|
|
|
if (this.games.has(game_id) === false) { return (JoinRes.no); }
|
|
const game : Pong = this.games.get(game_id)!;
|
|
if (game.local || (game.userLeft !== sock.authUser.id && game.userRight !== sock.authUser.id)) {
|
|
return (JoinRes.no);
|
|
}
|
|
game.userOnPage[game.userLeft === sock.authUser.id ? 0 : 1] = true;
|
|
if (game.userOnPage[0] === game.userOnPage[1]) {
|
|
this.initGame(game, game_id, game.userLeft, game.userRight);
|
|
}
|
|
return (JoinRes.yes);
|
|
}
|
|
|
|
public registerUser(socket: SSocket): void {
|
|
this.fastify.log.info('Registering new user');
|
|
if (this.users.has(socket.authUser.id)) {
|
|
socket.emit('forceDisconnect', 'Already Connected');
|
|
socket.disconnect();
|
|
return;
|
|
}
|
|
this.users.set(socket.authUser.id, {
|
|
socket,
|
|
id: socket.authUser.id,
|
|
windowId: socket.id,
|
|
updateInterval: setInterval(() => this.updateClient(socket), 100),
|
|
killSelfInterval: setInterval(
|
|
() => this.checkKillSelf(socket),
|
|
100,
|
|
),
|
|
currentGame: null,
|
|
lastSeen: Date.now(),
|
|
});
|
|
this.fastify.log.info('Registered new user');
|
|
|
|
socket.on('disconnect', () => this.cleanupUser(socket));
|
|
socket.on('enqueue', () => this.enqueueUser(socket));
|
|
socket.on('dequeue', () => this.dequeueUser(socket));
|
|
|
|
socket.on('readyUp', () => this.readyupUser(socket));
|
|
socket.on('readyDown', () => this.readydownUser(socket));
|
|
|
|
socket.on('gameMove', (e) => this.gameMove(socket, e));
|
|
socket.on('localGame', () => this.newLocalGame(socket));
|
|
socket.on('joinGame', (g_id, ack) => {return (ack(this.tryJoinGame(g_id, socket)));});
|
|
// todo: allow passing nickname
|
|
socket.on('tourRegister', () =>
|
|
this.registerForTournament(socket, null),
|
|
);
|
|
socket.on('tourUnregister', () => this.unregisterForTournament(socket));
|
|
|
|
socket.on('tourCreate', () => this.createTournament(socket));
|
|
socket.on('tourStart', () => this.startTournament(socket));
|
|
socket.on('hello', () => this.getHello(socket));
|
|
}
|
|
|
|
private updateClient(socket: SSocket): void {
|
|
socket.emit('updateInformation', {
|
|
inQueue: this.queue.size,
|
|
totalUser: this.users.size,
|
|
totalGames: this.games.size,
|
|
});
|
|
let tourInfo: TourInfo | null = null;
|
|
if (this.tournament !== null) {
|
|
tourInfo = {
|
|
ownerId: this.tournament.owner,
|
|
state: this.tournament.state,
|
|
players: this.tournament.users.values().toArray(),
|
|
remainingMatches:
|
|
this.tournament.state === 'playing'
|
|
? this.tournament.matchup.length
|
|
: null,
|
|
};
|
|
}
|
|
socket.emit('tournamentInfo', tourInfo);
|
|
}
|
|
|
|
private cleanupUser(socket: SSocket): void {
|
|
if (!this.users.has(socket.authUser.id)) return;
|
|
|
|
clearInterval(this.users.get(socket.authUser.id)?.updateInterval);
|
|
clearInterval(this.users.get(socket.authUser.id)?.killSelfInterval);
|
|
this.users.delete(socket.authUser.id);
|
|
this.queue.delete(socket.authUser.id);
|
|
|
|
// if the user is in the tournament, and the tournament owner isn't the owner => we remove the user from the tournament !
|
|
if (
|
|
this.tournament?.users.has(socket.authUser.id) &&
|
|
this.tournament?.owner !== socket.authUser.id
|
|
) {
|
|
this.tournament.removeUser(socket.authUser.id);
|
|
}
|
|
}
|
|
|
|
private async cleanupGame(gameId: GameId, game: Pong): Promise<void> {
|
|
let chat_text = 'A game ended between ';
|
|
clearInterval(game.gameUpdate ?? undefined);
|
|
if (game.onEnd) game.onEnd();
|
|
this.games.delete(gameId);
|
|
const winner = game.checkWinner() ?? 'left';
|
|
let player: PUser | undefined = undefined;
|
|
if ((player = this.users.get(game.userLeft)) !== undefined) {
|
|
player.currentGame = null;
|
|
player.socket.emit('gameEnd', winner);
|
|
}
|
|
chat_text +=
|
|
(this.fastify.db.getUser(game.userLeft)?.name ?? game.userLeft) +
|
|
' and ';
|
|
if ((player = this.users.get(game.userRight)) !== undefined) {
|
|
player.currentGame = null;
|
|
player.socket.emit('gameEnd', winner);
|
|
}
|
|
chat_text +=
|
|
this.fastify.db.getUser(game.userRight)?.name ?? game.userRight;
|
|
const rOutcome = game.checkWinner();
|
|
let outcome: PongGameOutcome = 'other';
|
|
if (rOutcome === 'left') {
|
|
outcome = 'winL';
|
|
}
|
|
if (rOutcome === 'right') {
|
|
outcome = 'winR';
|
|
}
|
|
this.fastify.db.setPongGameOutcome(
|
|
gameId,
|
|
{ id: game.userLeft, score: game.score[0] },
|
|
{ id: game.userRight, score: game.score[1] },
|
|
outcome,
|
|
game.local,
|
|
);
|
|
this.fastify.log.info('SetGameOutcome !');
|
|
if (!game.local) {
|
|
const payload = { nextGame: chat_text };
|
|
try {
|
|
const resp = await fetch('http://app-chat/api/chat/broadcast', {
|
|
method: 'POST',
|
|
headers: { 'Content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
throw resp;
|
|
}
|
|
else {
|
|
this.fastify.log.info('game-end info to chat success');
|
|
}
|
|
}
|
|
catch (e: unknown) {
|
|
this.fastify.log.error(`game-end info to chat failed: ${e}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private enqueueUser(socket: SSocket): void {
|
|
if (!this.users.has(socket.authUser.id)) return;
|
|
|
|
if (this.queue.has(socket.authUser.id)) return;
|
|
|
|
if (this.users.get(socket.authUser.id)?.currentGame !== null) return;
|
|
|
|
this.queue.add(socket.authUser.id);
|
|
socket.emit('queueEvent', 'registered');
|
|
}
|
|
|
|
private dequeueUser(socket: SSocket): void {
|
|
if (!this.users.has(socket.authUser.id)) return;
|
|
|
|
if (!this.queue.has(socket.authUser.id)) return;
|
|
|
|
this.queue.delete(socket.authUser.id);
|
|
socket.emit('queueEvent', 'unregistered');
|
|
}
|
|
|
|
private readydownUser(socket: SSocket): void {
|
|
// do we know this user ?
|
|
if (!this.users.has(socket.authUser.id)) return;
|
|
const user = this.users.get(socket.authUser.id)!;
|
|
// does the user have a game and do we know such game ?
|
|
if (user.currentGame === null || !this.games.has(user.currentGame)) {
|
|
return;
|
|
}
|
|
const game = this.games.get(user.currentGame)!;
|
|
// is this a local game?
|
|
if (game.local === true) return;
|
|
game.readydown(user.id);
|
|
}
|
|
private readyupUser(socket: SSocket): void {
|
|
// do we know this user ?
|
|
if (!this.users.has(socket.authUser.id)) return;
|
|
const user = this.users.get(socket.authUser.id)!;
|
|
// does the user have a game and do we know such game ?
|
|
if (user.currentGame === null || !this.games.has(user.currentGame)) {
|
|
return;
|
|
}
|
|
const game = this.games.get(user.currentGame)!;
|
|
if (game.local === true) return;
|
|
game.readyup(user.id);
|
|
}
|
|
}
|
|
|
|
export let State: StateI = undefined as unknown as StateI;
|
|
|
|
export function newState(f: FastifyInstance) {
|
|
State = new StateI(f);
|
|
} |