0
0
Fork 0

Merge pull request #33 from Oliver-Akins/dev

V1.1.0
This commit is contained in:
Oliver 2021-01-07 18:35:44 -07:00 committed by GitHub
commit 5c650edda4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 611 additions and 124 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ node_modules
server/server.toml server/server.toml
server/built/* server/built/*
server/dist/* server/dist/*
server/resources/games
#=============================================================================# #=============================================================================#
# The files that were auto-generated into a .gitignore by Vue-cli # The files that were auto-generated into a .gitignore by Vue-cli

View file

@ -1,20 +1,20 @@
import { games, log } from '../main';
import { Game } from '../objects/Game'; import { Game } from '../objects/Game';
import { Player } from '../objects/Player'; import { Player } from '../objects/Player';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { conf, games, log } from '../main'; import { routineCheck } from '../utils/cleanup';
export default (io: Server, socket: Socket, data: CreateGame) => { export default (io: Server, socket: Socket, data: CreateGame) => {
try { try {
let host = new Player(data.name, socket, true); let host = new Player(data.name, socket, true);
// Create the game object to save // Create the game object to save
let game = new Game(conf, host); let game = new Game(host);
games[game.id] = game; games[game.id] = game;
game.players.push(host);
game.log = log.getChildLogger({ game.log = log.getChildLogger({
displayLoggerName: true, displayLoggerName: true,
name: game.id, name: game.id,
}) });
game.log.info(`New game created (host=${host.name})`); game.log.info(`New game created (host=${host.name})`);
socket.join(game.id); socket.join(game.id);
@ -23,8 +23,12 @@ export default (io: Server, socket: Socket, data: CreateGame) => {
game_code: game.id, game_code: game.id,
players: game.playerData, players: game.playerData,
}); });
// Check for any inactive games that are still marked as active
routineCheck();
} }
catch (err) { catch (err) {
log.prettyError(err);
socket.emit(`GameCreated`, { socket.emit(`GameCreated`, {
status: 500, status: 500,
message: err.message, message: err.message,

View file

@ -33,9 +33,13 @@ export default (io: Server, socket: Socket, data: DeleteGame) => {
// Delete game // Delete game
game.log.debug(`Game deleted.`) game.log.debug(`Game deleted.`)
delete games[data.game_code]; delete games[data.game_code];
io.to(game.id).emit(`GameDeleted`, { status: 200 }); io.to(game.id).emit(`GameDeleted`, {
status: 200,
message: `Game deleted by the host.`
});
} }
catch (err) { catch (err) {
log.prettyError(err);
socket.emit(`GameDeleted`, { socket.emit(`GameDeleted`, {
status: 500, status: 500,
message: err.message, message: err.message,

View file

@ -23,6 +23,7 @@ export default (io: Server, socket: Socket, data: GetHand) => {
}); });
} }
catch (err) { catch (err) {
log.prettyError(err);
socket.emit(`QuestionList`, { socket.emit(`QuestionList`, {
status: 500, status: 500,
message: `${err.name}: ${err.message}`, message: `${err.name}: ${err.message}`,

View file

@ -24,6 +24,7 @@ export default (io: Server, socket: Socket, data: GetPastQuestions) => {
}); });
} }
catch (err) { catch (err) {
log.prettyError(err);
socket.emit(`PastQuestions`, { socket.emit(`PastQuestions`, {
status: 500, status: 500,
message: `${err.name}: ${err.message}`, message: `${err.name}: ${err.message}`,

View file

@ -1,9 +1,83 @@
import { games, log } from '../main'; import { readFileSync } from 'fs';
import { Game } from '../objects/Game';
import { Player } from '../objects/Player'; import { Player } from '../objects/Player';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { games, hibernatedGames, log, conf } from '../main';
export default (io: Server, socket: Socket, data: JoinGame) => { export default (io: Server, socket: Socket, data: JoinGame) => {
try { try {
// Check if the game is hibernated so that we can re-instantiate the
// Game object and bring it back to being alive
let hibernatedIndex = hibernatedGames.indexOf(data.game_code)
if (hibernatedIndex >= 0) {
log.info(`Recreating game from datastore.`);
let datastore = JSON.parse(readFileSync(
`${conf.datastores.directory}/${data.game_code}.${conf.datastores.filetype}`,
`utf-8`
)) as datastoreGame;
let host = new Player(data.name, socket, true);
let game = Game.fromJSON(host, datastore);
game.log = log.getChildLogger({
displayLoggerName: true,
name: game.id,
});
game.ingame = datastore.ingame;
// Get the specific information for team
let playerData = datastore.players.find(p => p.name === data.name);
if (playerData) {
host.role = playerData.role;
host.team = playerData.team;
};
let hand: string[] = [];
if (host.team) {
let team = game.teams[host.team - 1];
switch (host.role) {
case "guesser":
game.log.silly(`${host.name} is one of the team's guessers`);
hand = team.hand;
team.guessers.push(host);
socket.join([
`${game.id}:*:guesser`,
`${game.id}:${team.id}:guesser`
]);
break;
case "writer":
game.log.silly(`${host.name} is the team's writer`);
team.writer = host;
socket.join([
`${game.id}:*:writer`,
`${game.id}:${team.id}:writer`
]);
break;
};
game.log.debug(`Host assigned to team`);
};
hibernatedGames.splice(hibernatedIndex, 1);
games[game.id] = game;
game.log.info(`Successfully unhibernated`);
socket.join(game.id);
socket.emit(`GameRejoined`, {
status: 200,
ingame: game.ingame,
role: host.role,
team: host.team,
is_host: true,
players: game.playerData,
chosen_object: game.object,
hand: hand,
answers: {
team_1: game.teams[0].answers,
team_2: game.teams[1].answers,
},
});
return;
};
// Assert game exists // Assert game exists
if (!games[data.game_code]) { if (!games[data.game_code]) {
@ -18,30 +92,45 @@ export default (io: Server, socket: Socket, data: JoinGame) => {
let game = games[data.game_code]; let game = games[data.game_code];
// Ensure no one has the same name as the player that is joining /*
Ensure that if the socket is attempting to reconnect to the game, that
the player they are connecting to does not have actively connected
socket. This will also function as the main game joining for hibernated
games that were reloaded from disk.
*/
let sameName = game.players.find(x => x.name == data.name); let sameName = game.players.find(x => x.name == data.name);
if (sameName != null) { if (sameName != null) {
if (!game.ingame) {
game.log.info(`Client attempted to connect using name already in use.`); if (!sameName.socket?.connected) {
socket.emit(`GameJoined`, { sameName.socket = socket;
status: 400, game.log.info(`Player Reconnected to the game (name=${data.name})`);
message: `A player already has that name in the game.`,
source: `JoinGame` // Get the hand of the player's team
let hand: string[] = [];
if (sameName.team && sameName.role == `guesser`) {
hand = game.teams[sameName.team - 1].hand;
};
socket.emit(`GameRejoined`, {
status: 200,
ingame: game.ingame,
role: sameName.role,
team: sameName.team,
is_host: sameName.isHost,
players: game.playerData,
chosen_object: game.object,
answers: {
team_1: game.teams[0].answers,
team_2: game.teams[1].answers,
},
hand: hand,
}); });
return; return;
};
// Player has the same name but is allowed to rejoin if they
// disconnect in the middle of the game
if (!sameName.socket.connected) {
game.log.info(`Player Reconnected to the game (name=${data.name})`);
socket.emit(`GameRejoined`, { status: 200 });
return;
} else { } else {
game.log.debug(`${socket.id} attempted to claim ${sameName.socket.id}'s game spot.`); game.log.debug(`${socket.id} attempted to join with a name already in use ${data.name}`);
socket.emit(`GameJoined`, { socket.emit(`GameJoined`, {
status: 403, status: 403,
message: `Can't connect to an already connected client`, message: `A player already has that name in the game.`,
source: `JoinGame` source: `JoinGame`
}); });
return; return;
@ -77,6 +166,7 @@ export default (io: Server, socket: Socket, data: JoinGame) => {
}); });
} }
catch (err) { catch (err) {
log.prettyError(err);
socket.emit(`GameJoined`, { socket.emit(`GameJoined`, {
status: 500, status: 500,
message: `${err.name}: ${err.message}`, message: `${err.name}: ${err.message}`,

View file

@ -64,6 +64,7 @@ export default (io: Server, socket: Socket, data: LeaveGame) => {
}; };
} }
catch (err) { catch (err) {
log.prettyError(err);
socket.emit(`GameLeft`, { socket.emit(`GameLeft`, {
status: 500, status: 500,
message: `${err.name}: ${err.message}`, message: `${err.name}: ${err.message}`,

View file

@ -1,4 +1,4 @@
import { conf, games, log } from '../main'; import { games, log } from '../main';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: NewHand) => { export default (io: Server, socket: Socket, data: NewHand) => {
@ -18,6 +18,13 @@ export default (io: Server, socket: Socket, data: NewHand) => {
let team = game.teams[data.team - 1]; let team = game.teams[data.team - 1];
let deck = game.questions; let deck = game.questions;
/**
* The amount of cards that the team has in their hand prior to
* discarding all of their hand, this is used to make sure that they
* get back the same number of cards that they had in their hand.
*/
let handSize = team.hand.length;
// Empty the medium's hand to the discard pile so we know where the // Empty the medium's hand to the discard pile so we know where the
// cards are. // cards are.
for (var card of team.hand) { for (var card of team.hand) {
@ -27,7 +34,7 @@ export default (io: Server, socket: Socket, data: NewHand) => {
}; };
// Add the questions and then alert the game clients about the changes // Add the questions and then alert the game clients about the changes
team.addCardsToHand(deck.draw(conf.game.hand_size)); team.addCardsToHand(deck.draw(handSize));
game.log.silly(`Drew a new hand of cards for team ${data.team}.`); game.log.silly(`Drew a new hand of cards for team ${data.team}.`);
io.to(game.id).emit(`UpdateHand`, { io.to(game.id).emit(`UpdateHand`, {
status: 200, status: 200,
@ -36,6 +43,7 @@ export default (io: Server, socket: Socket, data: NewHand) => {
}); });
} }
catch (err) { catch (err) {
log.prettyError(err);
socket.emit(`UpdateHand`, { socket.emit(`UpdateHand`, {
status: 500, status: 500,
message: `${err.name}: ${err.message}`, message: `${err.name}: ${err.message}`,

View file

@ -7,7 +7,7 @@ export default (io: Server, socket: Socket, data: ObjectList) => {
// Assert game exists // Assert game exists
if (!games[data.game_code]) { if (!games[data.game_code]) {
log.debug(`Can't get objects for game that doesn't exist: ${data.game_code}`); log.debug(`Can't get objects for game that doesn't exist: ${data.game_code}`);
socket.emit(`Error`, { socket.emit(`ObjectList`, {
status: 404, status: 404,
message: `Game with code ${data.game_code} could not be found`, message: `Game with code ${data.game_code} could not be found`,
source: `ObjectList` source: `ObjectList`
@ -22,7 +22,8 @@ export default (io: Server, socket: Socket, data: ObjectList) => {
}); });
} }
catch (err) { catch (err) {
socket.emit(`Error`, { log.prettyError(err);
socket.emit(`ObjectList`, {
status: 500, status: 500,
message: `${err.name}: ${err.message}`, message: `${err.name}: ${err.message}`,
source: `ObjectList`, source: `ObjectList`,

View file

@ -0,0 +1,29 @@
import { games, log } from '../main';
import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: ResetGame) => {
try {
if (!games[data.game_code]) {
log.debug(`Can't find game with code: ${data.game_code}`);
socket.emit(`GameReset`, {
status: 404,
message: `Can't find game with code: ${data.game_code}`,
source: `ResetGame`
});
return;
};
let game = games[data.game_code];
game.questions.reset();
game.resetObject();
io.to(game.id).emit(`GameReset`, { status: 200 });
} catch (err) {
log.prettyError(err);
socket.emit(`GameReset`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: `ResetGame`,
});
}
};

View file

@ -35,6 +35,7 @@ export default (io: Server, socket: Socket, data: SelectObject) => {
}); });
} }
catch (err) { catch (err) {
log.prettyError(err);
socket.emit(`ChosenObject`, { socket.emit(`ChosenObject`, {
status: 500, status: 500,
message: `${err.name}: ${err.message}`, message: `${err.name}: ${err.message}`,

View file

@ -20,8 +20,11 @@ export default (io: Server, socket: Socket, data: SendCard) => {
// The writer is answering // The writer is answering
if (data.from === "writer") { if (data.from === "writer") {
game.log.debug(` Writer selected question to answer.`); game.log.debug(`Writer selected question to answer.`);
// Draw new cards for team
deck.discard(data.text); deck.discard(data.text);
team.addCardsToHand(game.questions.draw(conf.game.hand_size - team.hand.length));
team.selectQuestion(data.text); team.selectQuestion(data.text);
socket.emit(`UpdateHand`, { socket.emit(`UpdateHand`, {
@ -29,16 +32,20 @@ export default (io: Server, socket: Socket, data: SendCard) => {
mode: "replace", mode: "replace",
questions: [] questions: []
}); });
io.to(`${game.id}:${team.id}:guesser`).emit(`UpdateHand`, {
status: 200,
mode: "replace",
questions: team.hand
});
return; return;
} }
// The writer is sending the card to the writer // The writer is sending the card to the writer
else if (data.from === "guesser") { else if (data.from === "guesser") {
game.log.debug(`Guesser is sending the card to the writer.`); game.log.debug(`Guesser is sending a card to the writer.`);
// Update the team's hand // Update the team's hand
team.removeCard(data.text); team.removeCard(data.text);
team.addCardsToHand(game.questions.draw(conf.game.hand_size - team.hand.length));
// send the question text to the writer player // send the question text to the writer player
io.to(`${game.id}:${team.id}:writer`).emit(`UpdateHand`, { io.to(`${game.id}:${team.id}:writer`).emit(`UpdateHand`, {
@ -67,9 +74,10 @@ export default (io: Server, socket: Socket, data: SendCard) => {
}; };
} }
catch (err) { catch (err) {
log.prettyError(err);
socket.emit(`UpdateHand`, { socket.emit(`UpdateHand`, {
status: 500, status: 500,
message: `${err.name}: ${err.message}`, message: err.message,
source: `SendCard`, source: `SendCard`,
}); });
} }

View file

@ -7,7 +7,7 @@ export default (io: Server, socket: Socket, data: StartGame) => {
// Assert game exists // Assert game exists
if (!games[data.game_code]) { if (!games[data.game_code]) {
log.debug(`Could not find a game with ID ${data.game_code} to start`); log.debug(`Could not find a game with ID ${data.game_code} to start`);
socket.emit(`GameJoined`, { socket.emit(`GameStarted`, {
status: 404, status: 404,
message: `Game with code "${data.game_code}" could not be found`, message: `Game with code "${data.game_code}" could not be found`,
source: `StartGame`, source: `StartGame`,
@ -65,6 +65,7 @@ export default (io: Server, socket: Socket, data: StartGame) => {
io.to(game.id).emit(`GameStarted`, { status: 200 }); io.to(game.id).emit(`GameStarted`, { status: 200 });
} }
catch (err) { catch (err) {
log.prettyError(err);
socket.emit(`GameStarted`, { socket.emit(`GameStarted`, {
status: 500, status: 500,
message: `${err.name}: ${err.message}`, message: `${err.name}: ${err.message}`,

View file

@ -26,6 +26,7 @@ export default (io: Server, socket: Socket, data: UpdateAnswer) => {
}); });
} }
catch (err) { catch (err) {
log.prettyError(err);
socket.emit(`UpdateAnswer`, { socket.emit(`UpdateAnswer`, {
status: 500, status: 500,
message: `${err.name}: ${err.message}`, message: `${err.name}: ${err.message}`,

View file

@ -32,6 +32,7 @@ export default (io: Server, socket: Socket, data: UpdatePlayer) => {
}; };
} }
catch (err) { catch (err) {
log.prettyError(err);
socket.emit(`PlayerUpdate`, { socket.emit(`PlayerUpdate`, {
status: 500, status: 500,
message: `${err.name}: ${err.message}`, message: `${err.name}: ${err.message}`,

View file

@ -1,14 +1,16 @@
import { log } from '../main';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: any) => { export default (io: Server, socket: Socket, data: any) => {
try { try {
socket.emit(`Error`, { socket.emit(``, {
status: 501, status: 501,
message: `: Not Implemented Yet`, message: `: Not Implemented Yet`,
source: ``, source: ``,
}); });
} catch (err) { } catch (err) {
socket.emit(`Error`, { log.prettyError(err);
socket.emit(``, {
status: 500, status: 500,
message: `${err.name}: ${err.message}`, message: `${err.name}: ${err.message}`,
source: ``, source: ``,

View file

@ -1,12 +1,27 @@
import * as toml from "toml"; import * as toml from "toml";
import { Logger } from "tslog"; import { Logger } from "tslog";
import { readFileSync } from "fs";
import { Game } from "./objects/Game"; import { Game } from "./objects/Game";
import startWebsocket from "./websocket"; import startWebsocket from "./websocket";
import { Validate } from "./utils/validate"; import { Validate } from "./utils/validate";
import { processExit } from "./utils/cleanup";
import { readdirSync, readFileSync } from "fs";
export const conf: config = toml.parse(readFileSync(`server.toml`, `utf-8`)); export const conf: config = toml.parse(readFileSync(`server.toml`, `utf-8`));
/*
* These are the objects that we use to keep track of the different games,
* there are two different types of game, hibernated, and active. Hibernated
* games are games which have data associated with them in the datastore, but
* have not had any requests associated with them since they were hibernated.
* Active games are games that have active socket connections associated with
* them or have not been cleaned up by the game creation process yet. Active
* games become hibernated once the server has been closed while they are in
* the middle of a game, if they are in the lobby, the cleanup systems will
* just delete the game outright instead of saving it to disk. These methods
* allow us to keep games that are in the process of being played if/when the
* server crashes/restarts from having to completely start the game over again.
*/
export var hibernatedGames: string[] = [];
export var games: {[index: string]: Game} = {}; export var games: {[index: string]: Game} = {};
export const log: Logger = new Logger({ export const log: Logger = new Logger({
@ -18,6 +33,22 @@ export const log: Logger = new Logger({
name: `GLOBAL`, name: `GLOBAL`,
}); });
// Ensure the config valid
if (Validate.config(conf)) { if (Validate.config(conf)) {
// Add event listeners if we want to use the datastore saving game system
if (conf.datastores.enabled) {
log.info(`Loading list of hibernated games`);
// Get game IDs from datastore
hibernatedGames = readdirSync(conf.datastores.directory)
.filter(g => g.endsWith(conf.datastores.filetype))
.map(f => f.replace(`\.${conf.datastores.filetype}`, ``));
log.info(`Found ${hibernatedGames.length} hibernated games`);
process.on(`uncaughtException`, processExit);
process.on(`SIGINT`, processExit);
};
startWebsocket(conf); startWebsocket(conf);
} }

View file

@ -56,4 +56,33 @@ export class Deck<T> {
this._unknown = this._unknown.filter(x => x != card); this._unknown = this._unknown.filter(x => x != card);
this._discard.push(card); this._discard.push(card);
}; };
public reset() {
this._deck.push(...this._discard, ...this._unknown);
this._discard = [];
this._unknown = [];
};
public toJSON(): datastoreDeck<T> {
/**
* Converts this Deck into a JSON-compatible object
*/
return {
deck: this._deck,
unknown: this._unknown,
discard: this._discard,
};
};
public static fromJSON<A>(data: datastoreDeck<A>): Deck<A> {
/**
* Converts the JSON representation of a deck into a Deck
*/
let d = new Deck(data.deck);
d._discard = data.discard;
d._unknown = data.unknown;
return d;
};
}; };

View file

@ -1,41 +1,46 @@
import { Team } from "./Team"; import { Team } from "./Team";
import { Deck } from "./Deck"; import { Deck } from "./Deck";
import { readFile } from "fs";
import neatCSV from "neat-csv"; import neatCSV from "neat-csv";
import { Logger } from "tslog"; import { Logger } from "tslog";
import { games } from "../main";
import { Player } from "./Player"; import { Player } from "./Player";
import { readFile } from "fs"; import { games, hibernatedGames, conf } from "../main";
export class Game { export class Game {
readonly id: string; readonly id: string;
readonly host: Player; readonly host: Player;
public log: Logger; public log: Logger;
public ingame: boolean; public ingame: boolean;
public teams: [Team, Team]; public teams: Team[];
public players: Player[]; public players: Player[];
private _questions: Deck<question_deck>; private _questions: Deck<question_deck>;
private _objects: Deck<object_deck>; private _objects: Deck<object_deck>;
private _objectCard: string[]; private _objectCard: string[]|null;
public object: string; public object: string;
constructor(conf: config, host: Player) { constructor(host: Player, options:any=null) {
this.id = Game.generateID(conf.game.code_length);
this.host = host; this.host = host;
this.ingame = false; this.ingame = false;
this.players = []; this.players = [host];
this.id = options?.id || Game.generateID(conf.game.code_length);
// Get the decks based on what type of data they are. // If the object is being instantiated from JSON we don't want to do
switch (conf.game.cards.type) { // any of the stuff that requires weird per-game stuff
case "csv": if (!options) {
this.parseDeckCSV(conf);
break; // Get the decks based on what type of data they are.
case "sheets": switch (conf.game.cards.type) {
this.parseDeckGoogleSheets(conf); case "csv":
break; this.parseDeckCSV(conf);
break;
case "sheets":
this.parseDeckGoogleSheets(conf);
break;
};
// Instantiate everything for the teams
this.teams = [ new Team(1), new Team(2) ];
}; };
// Instantiate everything for the teams
this.teams = [ new Team(1), new Team(2) ];
}; };
get questions() { return this._questions; }; get questions() { return this._questions; };
@ -63,7 +68,7 @@ export class Game {
} }
private parseDeckCSV(conf: config): any { private parseDeckCSV(conf: config) {
/** /**
* Parses out the CSV files and creates the decks for the game to run on * Parses out the CSV files and creates the decks for the game to run on
* *
@ -97,7 +102,7 @@ export class Game {
}); });
}; };
private parseDeckGoogleSheets(conf: config): void { private parseDeckGoogleSheets(conf: config) {
/** /**
* Fetches and parses the CSV data from Google Sheets instead of local * Fetches and parses the CSV data from Google Sheets instead of local
* CSV files. * CSV files.
@ -107,6 +112,62 @@ export class Game {
}; };
public resetObject() {
/**
* Resets the objects card, for restarting the game
*/
if (this._objectCard) {
this._objects.discard(this._objectCard);
this._objectCard = null;
};
};
public toJSON(): datastoreGame {
/**
* Returns a JSON representation of the game.
*/
return {
players: this.players.map(p => p.toJSON()),
teams: this.teams.map(t => t.toJSON()),
decks: {
questions: this._questions.toJSON(),
objects: this._objects.toJSON(),
},
objectCard: this._objectCard,
object: this.object,
ingame: this.ingame,
id: this.id,
};
};
public static fromJSON(host: Player, data: datastoreGame): Game {
/**
* Converts a JSON representation into a Game object
*/
let game = new this(host, { id: data.id });
// Re-create the deck objects
game._questions = Deck.fromJSON<question_deck>(data.decks.questions);
game._objects = Deck.fromJSON<object_deck>(data.decks.objects);
game.teams = data.teams.map(t => Team.fromJSON(t));
// Re-instantiate all the players from the game.
for (var player of data.players) {
if (player.name !== host.name) {
player.host = false;
game.players.push(Player.fromJSON(player));
};
};
game._objectCard = data.objectCard;
game.object = data.object;
return game;
};
public static generateID(length: number): string { public static generateID(length: number): string {
/** /**
* Generates a game code with the given length * Generates a game code with the given length
@ -121,7 +182,7 @@ export class Game {
for (var i = 0; i < length; i++) { for (var i = 0; i < length; i++) {
code += `${Math.floor(Math.random() * 9)}`; code += `${Math.floor(Math.random() * 9)}`;
}; };
} while (games[code]); } while (games[code] || hibernatedGames.includes(code));
return code; return code;
}; };

View file

@ -4,12 +4,28 @@ export class Player {
readonly name: string; readonly name: string;
public team: team|null = null; public team: team|null = null;
public role: role|null = null; public role: role|null = null;
public socket: Socket; public socket: Socket|null;
readonly isHost: boolean; readonly isHost: boolean;
constructor(name: string, socket: Socket, isHost=false) { constructor(name: string, socket: Socket|null=null, isHost=false) {
this.name = name; this.name = name;
this.socket = socket; this.socket = socket;
this.isHost = isHost; this.isHost = isHost;
}; };
public toJSON(): datastorePlayer {
return {
name: this.name,
host: this.isHost,
team: this.team,
role: this.role,
};
};
public static fromJSON(data: datastorePlayer): Player {
let player = new this(data.name, null, data.host);
player.role = data.role;
player.team = data.team;
return player;
};
}; };

View file

@ -69,4 +69,28 @@ export class Team {
}; };
this._answers[answerIndex - 1] = answer; this._answers[answerIndex - 1] = answer;
}; };
public toJSON(): datastoreTeam {
/**
* Converts the given object into a JSON representation of the data
*/
return {
questions: this._questions,
answers: this._answers,
hand: this._hand,
id: this.id,
};
};
public static fromJSON(data: datastoreTeam): Team {
/**
* Converts a team JSON object back into a Team object.
*/
let t = new Team(data.id);
t._questions = data.questions;
t._answers = data.answers;
t._hand = data.hand;
return t;
};
}; };

View file

@ -6,9 +6,10 @@ interface config {
port: number; port: number;
permitted_hosts: string | string[]; permitted_hosts: string | string[];
}; };
webserver: { datastores: {
enabled: boolean; enabled: boolean;
port: number; filetype: string;
directory: string;
}; };
game: { game: {
hand_size: number; hand_size: number;

View file

@ -29,7 +29,9 @@ interface GameCreated extends response {
interface DeleteGame { interface DeleteGame {
game_code: string; game_code: string;
} }
interface GameDeleted extends response {} interface GameDeleted extends response {
message?: string
}
interface LeaveGame { interface LeaveGame {
@ -43,6 +45,11 @@ interface StartGame {
} }
interface GameStarted extends response {} interface GameStarted extends response {}
interface ResetGame {
game_code: string;
}
interface GameReset extends response {}
interface GetPastQuestions { interface GetPastQuestions {
game_code: string; game_code: string;

35
server/src/types/datastore.d.ts vendored Normal file
View file

@ -0,0 +1,35 @@
interface datastorePlayer {
team: team | null;
role: role | null;
host: boolean;
name: string;
}
type datastoreQuestionCard = string;
type datastoreObjectCard = string[];
interface datastoreTeam {
questions: datastoreQuestionCard[];
hand: datastoreQuestionCard[];
answers: string[];
id: team;
}
interface datastoreDeck<T> {
discard: T[];
unknown: T[];
deck: T[];
}
interface datastoreGame {
decks: {
questions: datastoreDeck<question_deck>;
objects: datastoreDeck<object_deck>;
};
objectCard: datastoreObjectCard|null;
players: datastorePlayer[];
teams: datastoreTeam[];
ingame: boolean;
object: string;
id: string;
}

View file

@ -0,0 +1,58 @@
import { writeFileSync } from "fs";
import { Game } from "../objects/Game";
import { games, conf, log } from "../main";
export function processExit() {
/**
* This is the cleanup code that runs when the server has been exited for
* any reason. We check all games if they have any active connection(s),
* then if they do, we save the game to disk, if not we just delete it
* completely from the system.
*/
log.info(`Cleaning up games`);
for (var gc in games) {
let game = games[gc];
if (game.ingame && activeGame(game)) {
game.log.debug(`Saving to datastore`);
writeFileSync(
`${conf.datastores.directory}/${game.id}.${conf.datastores.filetype}`,
JSON.stringify(game.toJSON())
);
} else {
game.log.debug(`Not saving to datastore`);
};
};
log.info(`Done cleaning up games`);
process.exit();
};
export async function routineCheck() {
/**
* This is the cleanup that occurs whenever a new game has been started
*/
log.info(`[routineCheck] Checking for games to clean up`)
for (var gc in games) {
let game = games[gc];
if (!activeGame(game)) {
game.log.debug(`[routineCheck] Deleting game`);
delete games[gc];
};
};
log.info(`[routineCheck] Done cleaning up games`);
};
function activeGame(game: Game): boolean {
/**
* This checks if a game is still active by checking that is at least one
* socket client still connected. If not then the game is considered
* stagnant and will be deleted by the cleanup code.
*/
for (var player of game.players) {
if (player.socket?.connected) {
return true;
};
};
return false;
};

View file

@ -27,16 +27,24 @@ export class Validate {
valid = false; valid = false;
} }
// Assert data in the web server object if (!conf.websocket.permitted_hosts) {
if (conf.webserver.enabled) { log.error(`Can't have a blank or null websocket.permitted_hosts`);
if (!conf.webserver.port) { valid = false;
log.error(`Invalid webserver port value: ${conf.webserver.port}`); };
if (!conf.datastores) {
log.error(`Datastores object must be defined`);
valid = false;
} else {
if (conf.datastores.enabled == null) {
log.error(`datastores.enabled must be defined`);
valid = false;
};
if (conf.datastores.enabled && conf.datastores.directory?.length == 0) {
log.error(`datastores.directory must be a filepath if datastores.enabled is set to true`);
valid = false; valid = false;
}; };
};
if (!conf.websocket.permitted_hosts) {
log.error(`Can't have a blank or null webserver.hostname`);
valid = false;
}; };
// Config is valid // Config is valid

View file

@ -5,6 +5,7 @@ import GetHand from "./events/GetHand";
import NewHand from "./events/NewHand"; import NewHand from "./events/NewHand";
import JoinGame from "./events/JoinGame"; import JoinGame from "./events/JoinGame";
import SendCard from "./events/SendCard"; import SendCard from "./events/SendCard";
import ResetGame from "./events/ResetGame";
import LeaveGame from "./events/LeaveGame"; import LeaveGame from "./events/LeaveGame";
import StartGame from "./events/StartGame"; import StartGame from "./events/StartGame";
import CreateGame from "./events/CreateGame"; import CreateGame from "./events/CreateGame";
@ -33,6 +34,7 @@ export default async (conf: config) => {
// Game Management // Game Management
socket.on(`CreateGame`, (data: CreateGame) => CreateGame(io, socket, data)); socket.on(`CreateGame`, (data: CreateGame) => CreateGame(io, socket, data));
socket.on(`StartGame`, (data: StartGame) => StartGame(io, socket, data)); socket.on(`StartGame`, (data: StartGame) => StartGame(io, socket, data));
socket.on(`ResetGame`, (data: ResetGame) => ResetGame(io, socket, data));
socket.on(`DeleteGame`, (data: DeleteGame) => DeleteGame(io, socket, data)); socket.on(`DeleteGame`, (data: DeleteGame) => DeleteGame(io, socket, data));

View file

@ -13,8 +13,7 @@
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-clipboard2": "^0.3.1", "vue-clipboard2": "^0.3.1",
"vue-socket.io-extended": "^4.0.5", "vue-socket.io-extended": "^4.0.5",
"vuex": "^3.4.0", "vuex": "^3.4.0"
"vuex-persist": "^3.1.3"
}, },
"devDependencies": { "devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0", "@vue/cli-plugin-babel": "~4.5.0",

22
web/pnpm-lock.yaml generated
View file

@ -5,7 +5,6 @@ dependencies:
vue-clipboard2: 0.3.1 vue-clipboard2: 0.3.1
vue-socket.io-extended: 4.0.5 vue-socket.io-extended: 4.0.5
vuex: 3.6.0_vue@2.6.12 vuex: 3.6.0_vue@2.6.12
vuex-persist: 3.1.3_vuex@3.6.0
devDependencies: devDependencies:
'@vue/cli-plugin-babel': 4.5.9_8ae91920fb9b3c76895c2e8acb765728 '@vue/cli-plugin-babel': 4.5.9_8ae91920fb9b3c76895c2e8acb765728
'@vue/cli-plugin-eslint': 4.5.9_6778c0324b153720448c6ab0d5359212 '@vue/cli-plugin-eslint': 4.5.9_6778c0324b153720448c6ab0d5359212
@ -3243,12 +3242,6 @@ packages:
node: '>=0.10.0' node: '>=0.10.0'
resolution: resolution:
integrity: sha512-95k0GDqvBjZavkuvzx/YqVLv/6YYa17fz6ILMSf7neqQITCPbnfEnQvEgMPNjH4kgobe7+WIL0yJEHku+H3qtQ== integrity: sha512-95k0GDqvBjZavkuvzx/YqVLv/6YYa17fz6ILMSf7neqQITCPbnfEnQvEgMPNjH4kgobe7+WIL0yJEHku+H3qtQ==
/deepmerge/4.2.2:
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
/default-gateway/4.2.0: /default-gateway/4.2.0:
dependencies: dependencies:
execa: 1.0.0 execa: 1.0.0
@ -4195,10 +4188,6 @@ packages:
dev: true dev: true
resolution: resolution:
integrity: sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== integrity: sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==
/flatted/3.1.0:
dev: false
resolution:
integrity: sha512-tW+UkmtNg/jv9CSofAKvgVcO7c2URjhTdW1ZTkcAritblu8tajiYy7YisnIflEwtKssCtOxpnBRoCB7iap0/TA==
/flush-write-stream/1.1.1: /flush-write-stream/1.1.1:
dependencies: dependencies:
inherits: 2.0.4 inherits: 2.0.4
@ -8744,16 +8733,6 @@ packages:
dev: false dev: false
resolution: resolution:
integrity: sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg== integrity: sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg==
/vuex-persist/3.1.3_vuex@3.6.0:
dependencies:
deepmerge: 4.2.2
flatted: 3.1.0
vuex: 3.6.0_vue@2.6.12
dev: false
peerDependencies:
vuex: '>=2.5'
resolution:
integrity: sha512-QWOpP4SxmJDC5Y1+0+Yl/F4n7z27syd1St/oP+IYCGe0X0GFio0Zan6kngZFufdIhJm+5dFGDo3VG5kdkCGeRQ==
/vuex/3.6.0_vue@2.6.12: /vuex/3.6.0_vue@2.6.12:
dependencies: dependencies:
vue: 2.6.12 vue: 2.6.12
@ -9161,4 +9140,3 @@ specifiers:
vue-socket.io-extended: ^4.0.5 vue-socket.io-extended: ^4.0.5
vue-template-compiler: ^2.6.11 vue-template-compiler: ^2.6.11
vuex: ^3.4.0 vuex: ^3.4.0
vuex-persist: ^3.1.3

View file

@ -95,7 +95,7 @@ export default {
this.handleError(data); this.handleError(data);
} else { } else {
this.alert = { this.alert = {
message: `The game has been ended by the host.`, message: data.message,
type: `info`, type: `info`,
}; };
this.$store.commit(`resetState`); this.$store.commit(`resetState`);

View file

@ -42,7 +42,6 @@ export default {
modal: false, modal: false,
tooling: { tooling: {
"Vue.JS (With VueX)": "https://vuejs.org", "Vue.JS (With VueX)": "https://vuejs.org",
"VueX-Persist": "https://www.npmjs.com/package/vuex-persist",
"Vue-Socket.io": "https://github.com/MetinSeylan/Vue-Socket.io", "Vue-Socket.io": "https://github.com/MetinSeylan/Vue-Socket.io",
"Vue-Clipboard2": "https://www.npmjs.com/package/vue-clipboard2", "Vue-Clipboard2": "https://www.npmjs.com/package/vue-clipboard2",
"Toml": "https://www.npmjs.com/package/toml", "Toml": "https://www.npmjs.com/package/toml",

View file

@ -10,8 +10,11 @@
a model attribute to keep them synced correctly. a model attribute to keep them synced correctly.
--> -->
<div <div
class="answer"
v-for="answerIndex in 8" v-for="answerIndex in 8"
:class="[
`answer`,
answers[`team_${3 - $store.state.team}`][answerIndex-1].toLowerCase() == $store.state.chosen_object+`.` ? `correct`: ``
]"
:key="`${otherTeamID}-answer-container-${answerIndex}`" :key="`${otherTeamID}-answer-container-${answerIndex}`"
> >
<input <input
@ -51,8 +54,11 @@
and having them be disabled for all other players and having them be disabled for all other players
--> -->
<div <div
class="answer"
v-for="answerIndex in 8" v-for="answerIndex in 8"
:class="[
`answer`,
answers[`team_${$store.state.team}`][answerIndex-1].toLowerCase() == $store.state.chosen_object+`.` ? `correct`: ``
]"
:key="`${teamID}-answer-container-${answerIndex}`" :key="`${teamID}-answer-container-${answerIndex}`"
> >
<input <input
@ -105,10 +111,6 @@ export default {
}, },
data() {return { data() {return {
visible: false, visible: false,
answers: {
team_1: [ ``, ``, ``, ``, ``, ``, ``, `` ],
team_2: [ ``, ``, ``, ``, ``, ``, ``, `` ],
},
}}, }},
computed: { computed: {
teamID() { teamID() {
@ -117,8 +119,18 @@ export default {
otherTeamID() { otherTeamID() {
return this.$store.getters.otherTeamName.replace(/\s/g, `-`).toLowerCase(); return this.$store.getters.otherTeamName.replace(/\s/g, `-`).toLowerCase();
}, },
answers() {
return this.$store.state.answers;
},
}, },
methods: { methods: {
isCorrect(team, answerIndex) {
let typedAnswer = this.answers[`team_${team}`][answerIndex - 1].toLowerCase();
if (this.$store.state.chosen_object == typedAnswer) {
return `correct`;
};
return ``;
},
answerInputHandler(answerIndex) { answerInputHandler(answerIndex) {
/** /**
* Sends input data updates to the server when they occur, indicating * Sends input data updates to the server when they occur, indicating
@ -147,7 +159,7 @@ export default {
* value: string * value: string
* } * }
*/ */
this.answers[`team_${data.team}`].splice(data.answer - 1, 1, data.value); this.$store.commit(`updateAnswer`, data);
}, },
}, },
} }
@ -191,6 +203,10 @@ h2 {
position: relative; position: relative;
width: 100%; width: 100%;
} }
.answer.correct > input {
border-color: green !important;
border-width: 3px;
}
.eye-container { .eye-container {
position: absolute; position: absolute;
@ -217,9 +233,10 @@ h2 {
} }
input[type="text"] { input[type="text"] {
font-family: var(--fonts);
background-color: var(--board-background-alt); background-color: var(--board-background-alt);
color: var(--board-background-alt-text); color: var(--board-background-alt-text);
font-family: var(--input-fonts);
text-transform: uppercase;
border-color: transparent; border-color: transparent;
border-style: solid; border-style: solid;
border-radius: 7px; border-radius: 7px;

View file

@ -1,8 +1,16 @@
<template> <template>
<div id="PlayerHand"> <div id="PlayerHand">
<div class="recentQuestion" v-if="mostRecentQuestion"> <div class="flex-center" v-if="mostRecentQuestion">
{{ mostRecentQuestion }} {{ mostRecentQuestion }}
</div> </div>
<div class="flex-center" v-else-if="gameOver">
<button
class="clickable"
@click.stop="endGame"
>
Go to Lobby
</button>
</div>
<div class="hand" v-else> <div class="hand" v-else>
<div <div
class="card" class="card"
@ -43,16 +51,21 @@ export default {
}, },
buttonLabel() { buttonLabel() {
if (this.isGuesser) { if (this.isGuesser) {
return this.$store.state.guesser_card_button return this.$store.state.guesser_card_button;
} else if (this.isWriter) { } else if (this.isWriter) {
return this.$store.state.writer_card_button return this.$store.state.writer_card_button;
} else { } else {
return `Unknown Role` return `Unknown Role`;
} }
}, },
questions() { questions() {
return this.$store.state.questions; return this.$store.state.questions;
} },
gameOver() {
let targetAnswer = this.$store.state.chosen_object.toLowerCase()+`.`;
return this.$store.state.answers.team_1.includes(targetAnswer)
|| this.$store.state.answers.team_2.includes(targetAnswer);
},
}, },
methods: { methods: {
sendCard(cardIndex) { sendCard(cardIndex) {
@ -74,7 +87,12 @@ export default {
}; };
this.$socket.client.emit(`SendCard`, data); this.$socket.client.emit(`SendCard`, data);
} },
endGame() {
this.$socket.client.emit(`ResetGame`, {
game_code: this.$store.state.game_code
});
},
}, },
mounted() { mounted() {
if (this.isGuesser) { if (this.isGuesser) {
@ -127,7 +145,7 @@ export default {
width: 95%; width: 95%;
} }
.recentQuestion { .flex-center {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
display: flex; display: flex;

View file

@ -1,9 +1,12 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto+Slab&display=swap');
:root { :root {
/* /*
The fonts and font colours the site will use The fonts and font colours the site will use
*/ */
--fonts: "Roboto", "Open Sans", sans-serif; --fonts: "Roboto", "Open Sans", sans-serif;
--input-fonts: "Roboto Slab", var(--fonts);
--light-font-colour: #ECE3BB; --light-font-colour: #ECE3BB;
--dark-font-colour: #000F3D; --dark-font-colour: #000F3D;

View file

@ -7,8 +7,14 @@ import VueSocketIOExt from 'vue-socket.io-extended';
Vue.config.productionTip = false; Vue.config.productionTip = false;
// Get the URI for dev enfironments
let websocket_uri = `/`;
if (process.env.NODE_ENV === `development`) {
websocket_uri = `http://${window.location.hostname}:8081`;
};
Vue.use(clipboard); Vue.use(clipboard);
Vue.use(VueSocketIOExt, io(`http://${window.location.hostname}:8081`)); Vue.use(VueSocketIOExt, io(websocket_uri));
new Vue({ new Vue({
store, store,

View file

@ -1,6 +1,5 @@
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import VuexPersistence from 'vuex-persist';
Vue.use(Vuex); Vue.use(Vuex);
@ -11,9 +10,9 @@ export default new Vuex.Store({
icon: `sun.svg`, icon: `sun.svg`,
eyes: { eyes: {
1: 0, 2: 0, 1: 0, 2: 0,
3: 0, 4: 0, 3: 0, 4: 1,
5: 0, 6: 0, 5: 0, 6: 1,
7: 0, 8: 0, 7: 1, 8: 0,
}, },
}, },
team_2: { team_2: {
@ -21,8 +20,8 @@ export default new Vuex.Store({
icon: `moon.svg`, icon: `moon.svg`,
eyes: { eyes: {
1: 0, 2: 0, 1: 0, 2: 0,
3: 0, 4: 0, 3: 1, 4: 0,
5: 0, 6: 0, 5: 1, 6: 1,
7: 0, 8: 0, 7: 0, 8: 0,
}, },
}, },
@ -47,6 +46,10 @@ export default new Vuex.Store({
questions: [], questions: [],
game_code: null, game_code: null,
players: [], players: [],
answers: {
team_1: [ ``, ``, ``, ``, ``, ``, ``, `` ],
team_2: [ ``, ``, ``, ``, ``, ``, ``, `` ],
}
}, },
getters: { getters: {
teamName(state) { teamName(state) {
@ -73,6 +76,10 @@ export default new Vuex.Store({
state.questions = []; state.questions = [];
state.game_code = null; state.game_code = null;
state.players = []; state.players = [];
state.answers = {
team_1: new Array(8).fill(``),
team_2: new Array(8).fill(``),
};
}, },
player(state, data) { player(state, data) {
if (data.name) if (data.name)
@ -84,7 +91,7 @@ export default new Vuex.Store({
if (data.host) if (data.host)
state.is_host = data.host state.is_host = data.host
}, },
game_code(state, game_code) { gameCode(state, game_code) {
state.game_code = game_code; state.game_code = game_code;
}, },
view(state, target) { view(state, target) {
@ -113,13 +120,16 @@ export default new Vuex.Store({
appendToHand(state, questions) { appendToHand(state, questions) {
state.questions.push(...questions); state.questions.push(...questions);
}, },
updateAnswer(state, data) {
state.answers[`team_${data.team}`].splice(data.answer - 1, 1, data.value)
},
setAnswers(state, data) {
state.answers.team_1 = data.team_1;
state.answers.team_2 = data.team_2;
},
}, },
actions: { actions: {
}, },
modules: { modules: {
}, },
plugins:
process.env.NODE_ENV === `production`
? [new VuexPersistence({ key: `ghost-writer-save` }).plugin]
: []
}); });

View file

@ -71,7 +71,7 @@ export default {
// Save the data in the store // Save the data in the store
this.$store.commit(`playerList`, data.players); this.$store.commit(`playerList`, data.players);
this.$store.commit(`game_code`, this.game_code); this.$store.commit(`gameCode`, this.game_code);
this.$store.commit(`player`, { this.$store.commit(`player`, {
name: this.name, name: this.name,
host: false, host: false,
@ -89,7 +89,7 @@ export default {
// Update storage // Update storage
this.$store.commit(`playerList`, data.players); this.$store.commit(`playerList`, data.players);
this.$store.commit(`game_code`, data.game_code); this.$store.commit(`gameCode`, data.game_code);
this.$store.commit(`player`, { this.$store.commit(`player`, {
name: this.name, name: this.name,
host: true, host: true,
@ -97,11 +97,42 @@ export default {
this.$store.commit(`view`, `lobby`); this.$store.commit(`view`, `lobby`);
}, },
GameRejoined(data) { GameRejoined(data) {
/**
* data = {
* status: integer,
* ingame: boolean,
* role: role,
* team: team,
* is_host: boolean,
* players: Player[],
* chosen_object: string,
* hand: string[],
* answers: {
* team_1: string[],
* team_2: string[],
* },
* },
*/
console.log(data)
if (!(200 <= data.status && data.status < 300)) { if (!(200 <= data.status && data.status < 300)) {
this.$emit(`error`, data); this.$emit(`error`, data);
return; return;
}; };
// TODO -> Update all data that is received from the server this.$store.commit(`resetState`);
this.$store.commit(`player`, {
name: this.name,
host: data.is_host,
role: data.role,
team: data.team,
});
this.$store.commit(`setObject`, data.chosen_object);
this.$store.commit(`view`, data.ingame ? `in-game` : `lobby`);
this.$store.commit(`setAnswers`, data.answers);
this.$store.commit(`playerList`, data.players);
this.$store.commit(`replaceHand`, data.hand);
history.replaceState(null, ``, `?game=${this.game_code}`);
this.$store.commit(`gameCode`, this.game_code);
}, },
}, },
mounted() {}, mounted() {},