Initial v2

This commit is contained in:
Gareth Latty
2019-06-22 04:37:15 +01:00
parent 9b74f7ed4a
commit dcdf011304
379 changed files with 36123 additions and 10481 deletions
+39
View File
@@ -0,0 +1,39 @@
import { Handler } from "./action/handler";
import * as validation from "./action/validation.validator";
import * as authenticate from "./action/authenticate";
import { Authenticate } from "./action/authenticate";
import * as gameAction from "./action/game-action";
import { GameAction } from "./action/game-action";
import * as privileged from "./action/privileged";
import { Privileged } from "./action/privileged";
import { AlreadyAuthenticatedError } from "./errors/authentication";
import { InvalidActionError } from "./errors/validation";
import * as util from "./util";
/**
* An action a user takes to affect the game in some way, received via a
* websocket (therefore with no hints as to what the action will be).
*/
export type Action = Authenticate | GameAction | Privileged;
const _validateAction = validation.validate("Action");
export const validate = (action: object): Action => {
try {
return _validateAction(action);
} catch (e) {
throw new InvalidActionError(e.message);
}
};
export const handle: Handler<Action> = (auth, lobby, action, config) => {
const validated = validate(action);
if (authenticate.is(validated)) {
throw new AlreadyAuthenticatedError();
} else if (gameAction.is(validated)) {
return gameAction.handle(auth, lobby, validated, config);
} else if (privileged.is(validated)) {
return privileged.handle(auth, lobby, validated, config);
} else {
return util.assertNever(validated);
}
};
+61
View File
@@ -0,0 +1,61 @@
import { InvalidAuthenticationError } from "../errors/authentication";
import * as event from "../event";
import { ServerState } from "../server-state";
import { GameCode } from "../lobby/game-code";
import { User } from "../user";
import * as token from "../user/token";
import { Token } from "../user/token";
import { Action } from "../action";
import * as change from "../lobby/change";
import * as connectionChanged from "../events/lobby-event/connection-changed";
/**
* Authenticate with the game.
*/
export interface Authenticate {
action: NameType;
token: Token;
}
type NameType = "Authenticate";
const name: NameType = "Authenticate";
/**
* Check if an action is an authenticate action.
* @param action The action to check.
*/
export const is = (action: Action): action is Authenticate =>
action.action === name;
/**
* Handle the authentication, returning the validated claim if successful.
* @param server The server state.
* @param authenticate The action.
* @param gameCode The game code for the lobby to validate in.
*/
export async function handle(
server: ServerState,
authenticate: Authenticate,
gameCode: GameCode
): Promise<token.Claims> {
const claims = token.validate(
authenticate.token,
await server.store.id(),
server.config.secret
);
if (claims.gc !== gameCode) {
throw new InvalidAuthenticationError("wrong game code");
}
await change.apply(server, gameCode, lobby => {
const user = lobby.users.get(claims.uid) as User;
if (user.connection !== "Connected") {
user.connection = "Connected";
return {
events: [event.target(connectionChanged.connected(claims.uid))]
};
} else {
return {};
}
});
return claims;
}
+88
View File
@@ -0,0 +1,88 @@
import { Action } from "../action";
import {
GameNotStartedError,
IncorrectPlayerRoleError
} from "../errors/action-execution-error";
import { Game } from "../games/game";
import * as player from "../games/player";
import * as gameLobby from "../lobby";
import * as token from "../user/token";
import * as util from "../util";
import * as czar from "./game-action/czar";
import { Czar } from "./game-action/czar";
import * as playerAction from "./game-action/player";
import { Player as PlayerAction } from "./game-action/player";
import * as redraw from "./game-action/redraw";
import { Redraw } from "./game-action/redraw";
import * as handler from "./handler";
import { Handler } from "./handler";
import wu = require("wu");
/**
* An action only a player can perform.
*/
export type GameAction = PlayerAction | Czar | Redraw;
/**
* A handler for game actions.
*/
export type Handler<T extends GameAction> = handler.Custom<
T,
gameLobby.WithActiveGame
>;
const possible = new Set([playerAction.is, czar.is, redraw.is]);
/**
* Check if an action is a configure action.
* @param action The action to check.
*/
export const is = (action: Action): action is GameAction =>
wu(possible).some(is => is(action));
export const handle: Handler<GameAction> = (auth, lobby, action, server) => {
if (gameLobby.hasActiveGame(lobby)) {
if (czar.is(action)) {
return czar.handle(auth, lobby, action, server);
} else if (playerAction.is(action)) {
return playerAction.handle(auth, lobby, action, server);
} else if (redraw.is(action)) {
return redraw.handle(auth, lobby, action, server);
} else {
return util.assertNever(action);
}
} else {
throw new GameNotStartedError(action);
}
};
export function expectRole(
auth: token.Claims,
action: Czar,
game: Game,
expected: "Czar"
): void;
export function expectRole(
auth: token.Claims,
action: PlayerAction,
game: Game,
expected: "Player"
): void;
/**
* Expect a given role.
* @param auth The claims for the user attempting to perform the action.
* @param action The action being performed.
* @param game The game being played.
* @param expected The expected role for the user.
*/
export function expectRole(
auth: token.Claims,
action: GameAction,
game: Game,
expected: player.Role
): void {
const playerRole = player.role(game, auth.uid);
if (playerRole !== expected) {
throw new IncorrectPlayerRoleError(action, playerRole, expected);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Action } from "../../action";
import * as util from "../../util";
import * as gameAction from "../game-action";
import * as judge from "./czar/judge";
import { Judge } from "./czar/judge";
import * as reveal from "./czar/reveal";
import { Reveal } from "./czar/reveal";
import wu = require("wu");
/**
* An action only the czar can perform.
*/
export type Czar = Judge | Reveal;
const possible = new Set([judge.is, reveal.is]);
/**
* Check if an action is a player action.
* @param action The action to check.
*/
export const is = (action: Action): action is Czar =>
wu(possible).some(is => is(action));
export const handle: gameAction.Handler<Czar> = (
auth,
lobby,
action,
server
) => {
gameAction.expectRole(auth, action, lobby.game, "Czar");
if (judge.is(action)) {
return judge.handle(auth, lobby, action, server);
} else if (reveal.is(action)) {
return reveal.handle(auth, lobby, action, server);
} else {
return util.assertNever(action);
}
};
@@ -0,0 +1,59 @@
import { Action } from "../../../action";
import { InvalidActionError } from "../../../errors/validation";
import * as roundFinished from "../../../events/game-event/round-finished";
import * as play from "../../../games/cards/play";
import * as round from "../../../games/game/round";
import * as gameAction from "../../game-action";
import * as event from "../../../event";
import * as roundStart from "../../../timeout/round-start";
/**
* A user declares the winning play for a round.
*/
export interface Judge {
action: NameType;
winner: play.Id;
}
type NameType = "Judge";
const name: NameType = "Judge";
export const is = (action: Action): action is Judge => action.action === name;
export const handle: gameAction.Handler<Judge> = (
auth,
lobby,
action,
server
) => {
const lobbyRound = lobby.game.round;
const plays = lobbyRound.plays;
if (round.verifyStage<round.Judging>(action, lobbyRound, "Judging")) {
const play = plays.find(play => play.id === action.winner);
if (play === undefined) {
throw new InvalidActionError("Given play doesn't exist.");
}
const player = lobby.game.players.get(play.playedBy);
if (player !== undefined) {
player.score += 1;
}
const completedRound: round.Complete = {
...lobbyRound,
stage: "Complete",
winner: play.playedBy
};
lobby.game.round = completedRound;
return {
lobby,
events: [event.target(roundFinished.of(completedRound))],
timeouts: [
{
timeout: roundStart.of(),
after: server.config.timeouts.nextRoundDelay
}
]
};
} else {
return {};
}
};
@@ -0,0 +1,56 @@
import { Action } from "../../../action";
import { InvalidActionError } from "../../../errors/validation";
import * as event from "../../../event";
import * as playRevealed from "../../../events/game-event/play-revealed";
import * as play from "../../../games/cards/play";
import * as gameAction from "../../game-action";
import * as round from "../../../games/game/round";
/**
* A user judges the winning play for a round.
*/
export interface Reveal {
action: NameType;
play: play.Id;
}
type NameType = "Reveal";
const name: NameType = "Reveal";
/**
* Check if an action is a reveal action.
* @param action The action to check.
*/
export const is = (action: Action): action is Reveal => action.action === name;
/**
* Handle a Judge action.
* @param auth The claims for the user attempting to perform the action.
* @param lobby The lobby the user is attempting to perform the action in.
* @param action The action.
*/
export const handle: gameAction.Handler<Reveal> = (auth, lobby, action) => {
const lobbyRound = lobby.game.round;
if (round.verifyStage<round.Revealing>(action, lobbyRound, "Revealing")) {
const play = lobbyRound.plays.find(play => play.id === action.play);
if (play === undefined) {
throw new InvalidActionError("Given play doesn't exist.");
}
if (play.revealed) {
throw new InvalidActionError("Given play is already revealed.");
}
play.revealed = true;
if (round.allStoredPlaysAreRevealed(lobbyRound)) {
lobby.game.round = {
...lobbyRound,
stage: "Judging"
};
}
return {
lobby,
events: [event.target(playRevealed.of(play.id, play.play))]
};
} else {
return {};
}
};
@@ -0,0 +1,37 @@
import { Action } from "../../action";
import * as util from "../../util";
import * as gameAction from "../game-action";
import * as submit from "./player/submit";
import { Submit } from "./player/submit";
import * as takeBack from "./player/take-back";
import { TakeBack } from "./player/take-back";
import wu from "wu";
/**
* An action only the czar can perform.
*/
export type Player = Submit | TakeBack;
const possible = new Set([submit.is, takeBack.is]);
/**
* Check if an action is a non-czar action.
* @param action The action to check.
*/
export const is = (action: Action): action is Player =>
wu(possible).some(is => is(action));
export const handle: gameAction.Handler<Player> = (
auth,
lobby,
action,
server
) => {
if (submit.is(action)) {
return submit.handle(auth, lobby, action, server);
} else if (takeBack.is(action)) {
return takeBack.handle(auth, lobby, action, server);
} else {
return util.assertNever(action);
}
};
@@ -0,0 +1,86 @@
import wu from "wu";
import { Action } from "../../../action";
import { IncorrectUserRoleError } from "../../../errors/action-execution-error";
import { InvalidActionError } from "../../../errors/validation";
import * as event from "../../../event";
import * as playSubmitted from "../../../events/game-event/play-submitted";
import * as card from "../../../games/cards/card";
import * as play from "../../../games/cards/play";
import { Play } from "../../../games/cards/play";
import * as finishedPlaying from "../../../timeout/finished-playing";
import * as gameAction from "../../game-action";
import * as round from "../../../games/game/round";
/**
* A player plays a white card into a round.
*/
export interface Submit {
action: "Submit";
play: card.Id[];
}
type NameType = "Submit";
const name: NameType = "Submit";
/**
* Check if an action is a submit action.
* @param action The action to check.
*/
export const is = (action: Action): action is Submit => action.action === name;
export const handle: gameAction.Handler<Submit> = (
auth,
lobby,
action,
server
) => {
const lobbyRound = lobby.game.round;
if (round.verifyStage<round.Playing>(action, lobbyRound, "Playing")) {
const playId = play.id();
const plays = lobbyRound.plays;
if (plays.find(play => play.playedBy === auth.uid)) {
throw new InvalidActionError("Already played into round.");
}
const playLength = action.play.length;
const slotCount = card.slotCount(lobbyRound.call);
if (playLength !== slotCount) {
throw new InvalidActionError(
"The play must have the same number of responses as the call " +
`has slots (expected ${slotCount}, got ${playLength}).`
);
}
const player = lobby.game.players.get(auth.uid);
if (player === undefined) {
throw new IncorrectUserRoleError(action, "Spectator", "Player");
}
const ids = new Set(action.play);
const extractedPlay: Play = [];
for (const playedId of ids) {
const played = player.hand.find(card => card.id === playedId);
if (played === undefined) {
throw new InvalidActionError(
"The given card doesn't exist or isn't in the player's hand."
);
}
extractedPlay.push(played);
}
plays.push({
id: playId,
play: extractedPlay,
playedBy: auth.uid,
revealed: false
});
const events = [event.target(playSubmitted.of(auth.uid))];
const timeouts = [];
const timeout = finishedPlaying.ifNeeded(lobbyRound);
if (timeout !== undefined) {
timeouts.push({
timeout: timeout,
after: server.config.timeouts.nextRoundDelay
});
}
return { lobby, events, timeouts };
} else {
return {};
}
};
@@ -0,0 +1,37 @@
import { Action } from "../../../action";
import { InvalidActionError } from "../../../errors/validation";
import * as event from "../../../event";
import * as playTakenBack from "../../../events/game-event/play-taken-back";
import * as gameAction from "../../game-action";
import * as round from "../../../games/game/round";
/**
* A player plays a white card into a round.
*/
export interface TakeBack {
action: "TakeBack";
}
type NameType = "TakeBack";
const name: NameType = "TakeBack";
/**
* Check if an action is a take back action.
* @param action The action to check.
*/
export const is = (action: Action): action is TakeBack =>
action.action === name;
export const handle: gameAction.Handler<TakeBack> = (auth, lobby, action) => {
if (round.verifyStage<round.Playing>(action, lobby.game.round, "Playing")) {
const plays = lobby.game.round.plays;
const playIndex = plays.findIndex(play => play.playedBy === auth.uid);
if (playIndex < 0) {
throw new InvalidActionError("No play to take back.");
}
plays.splice(playIndex, 1);
return { lobby, events: [event.target(playTakenBack.of(auth.uid))] };
} else {
return {};
}
};
@@ -0,0 +1,48 @@
import { Action } from "../../action";
import { IncorrectUserRoleError } from "../../errors/action-execution-error";
import { InvalidActionError } from "../../errors/validation";
import * as event from "../../event";
import * as handRedrawn from "../../events/game-event/hand-redrawn";
import * as gameAction from "../game-action";
/**
* A player plays a white card into a round.
*/
export interface Redraw {
action: "Redraw";
}
type NameType = "Redraw";
const name: NameType = "Redraw";
/**
* Check if an action is a take back action.
* @param action The action to check.
*/
export const is = (action: Action): action is Redraw => action.action === name;
export const handle: gameAction.Handler<Redraw> = (auth, lobby, action) => {
const game = lobby.game;
const reboot = game.rules.houseRules.reboot;
if (reboot === undefined) {
throw new InvalidActionError("Redraw house rule not enabled.");
}
const cost = reboot.cost;
const player = game.players.get(auth.uid);
if (player === undefined) {
throw new IncorrectUserRoleError(action, "Spectator", "Player");
}
if (player.score < cost) {
throw new InvalidActionError("Can't afford to redraw.");
}
player.score -= cost;
player.hand = game.decks.responses.replace(...player.hand);
return {
lobby,
events: event.targetByPlayer(
auth.uid,
handRedrawn.of(auth.uid, player.hand),
handRedrawn.censor
)
};
};
+21
View File
@@ -0,0 +1,21 @@
import { Action } from "../action";
import { Change } from "../lobby/change";
import { Lobby } from "../lobby";
import * as token from "../user/token";
import { ServerState } from "../server-state";
/**
* A handler for a given type of action where the lobby is customised.
* This can let us avoid making the same checks down the line.
*/
export type Custom<A extends Action, L extends Lobby> = (
auth: token.Claims,
lobby: L,
action: A,
server: ServerState
) => Change;
/**
* A handler for a given type of action.
*/
export type Handler<A extends Action> = Custom<A, Lobby>;
@@ -0,0 +1,19 @@
import { InvalidActionError } from "../../errors/validation";
import { Token } from "../../user/token";
import * as validation from "../validation.validator";
/**
* Previously obtained tokens to check the validity of.
*/
export interface CheckAlive {
tokens: Token[];
}
const _validateCheckAlive = validation.validate("CheckAlive");
export const validate = (action: object): CheckAlive => {
try {
return _validateCheckAlive(action);
} catch (e) {
throw new InvalidActionError(e.message);
}
};
@@ -0,0 +1,26 @@
import { InvalidActionError } from "../../errors/validation";
import * as validation from "../validation.validator";
import { RegisterUser } from "./register-user";
/**
* The details needed to create a new lobby.
*/
export interface CreateLobby {
/**
* The name of the lobby, if not given, will default to "Name's Game".
*/
name?: string;
/**
* The registration for the owner of the lobby.
*/
owner: RegisterUser;
}
const _validateCreateLobby = validation.validate("CreateLobby");
export const validate = (action: object): CreateLobby => {
try {
return _validateCreateLobby(action);
} catch (e) {
throw new InvalidActionError(e.message);
}
};
@@ -0,0 +1,26 @@
import { InvalidActionError } from "../../errors/validation";
import * as user from "../../user";
import * as validation from "../validation.validator";
/**
* The details to register a new user for a lobby.
*/
export interface RegisterUser {
/**
* The name the user wishes to use.
*/
name: user.Name;
/**
* The lobby password, if there is one, this must be given and correct.
*/
password?: string;
}
const _validateRegisterUser = validation.validate("RegisterUser");
export const validate = (action: object): RegisterUser => {
try {
return _validateRegisterUser(action);
} catch (e) {
throw new InvalidActionError(e.message);
}
};
+36
View File
@@ -0,0 +1,36 @@
import wu from "wu";
import { Action } from "../action";
import { UnprivilegedError } from "../errors/action-execution-error";
import * as util from "../util";
import { Handler } from "./handler";
import { Configure } from "./privileged/configure";
import * as configure from "./privileged/configure";
import * as startGame from "./privileged/start-game";
import { StartGame } from "./privileged/start-game";
/**
* An action only a privileged user can perform.
*/
export type Privileged = Configure | StartGame;
const possible = new Set([configure.is, startGame.is]);
/**
* Check if an action is a configure action.
* @param action The action to check.
*/
export const is = (action: Action): action is Privileged =>
wu(possible).some(is => is(action));
export const handle: Handler<Privileged> = (auth, lobby, action, server) => {
if (auth.pvg !== "Privileged") {
throw new UnprivilegedError(action);
}
if (configure.is(action)) {
return configure.handle(auth, lobby, action, server);
} else if (startGame.is(action)) {
return startGame.handle(auth, lobby, action, server);
} else {
return util.assertNever(action);
}
};
@@ -0,0 +1,69 @@
import wu from "wu";
import { Action } from "../../action";
import { ConfigEditConflictError } from "../../errors/action-execution-error";
import { Handler } from "../handler";
import { ChangeDecks } from "./configure/change-decks";
import * as changeDecks from "./configure/change-decks";
import * as setHandSize from "./configure/set-hand-size";
import { SetHandSize } from "./configure/set-hand-size";
import * as setPassword from "./configure/set-password";
import { SetPassword } from "./configure/set-password";
import * as setScoreLimit from "./configure/set-score-limit";
import { SetScoreLimit } from "./configure/set-score-limit";
import * as changeHouseRule from "./configure/change-house-rule";
import { ChangeHouseRule } from "./configure/change-house-rule";
/**
* An action to change the configuration of the lobby.
*/
export type Configure =
| SetPassword
| SetHandSize
| SetScoreLimit
| ChangeDecks
| ChangeHouseRule;
const possible = new Set([
setPassword.is,
setHandSize.is,
setScoreLimit.is,
changeDecks.is,
changeHouseRule.is
]);
/**
* Check if an action is a configure action.
* @param action The action to check.
*/
export const is = (action: Action): action is Configure =>
wu(possible).some(is => is(action));
/**
* A base for all config actions.
*/
export interface Base {
/**
* If the config version doesn't match this, the operation will be rejected.
* This avoids users accidentally overwriting each other's changes.
*/
if: string;
}
export const handle: Handler<Configure> = (auth, lobby, action, config) => {
const version = lobby.config.version.toString();
if (action.if !== version) {
throw new ConfigEditConflictError(action, action.if, version);
}
switch (action.action) {
case "SetPassword":
return setPassword.handle(auth, lobby, action, config);
case "SetHandSize":
return setHandSize.handle(auth, lobby, action, config);
case "SetScoreLimit":
return setScoreLimit.handle(auth, lobby, action, config);
case "ChangeDecks":
return changeDecks.handle(auth, lobby, action, config);
case "ChangeHouseRule":
return changeHouseRule.handle(auth, lobby, action, config);
}
};
@@ -0,0 +1,72 @@
import { Action } from "../../../action";
import * as event from "../../../event";
import { DecksChanged } from "../../../events/lobby-event/configured/decks-changed";
import * as source from "../../../games/cards/source";
import * as sources from "../../../games/cards/sources";
import * as config from "../../../lobby/config";
import { LoadDeckSummary } from "../../../task/load-deck-summary";
import { Handler } from "../../handler";
import * as configure from "../configure";
/**
* Make a change to the configuration of decks for the lobby.
*/
export interface ChangeDecks extends configure.Base {
action: NameType;
deck: source.External;
change: config.PlayerDriven;
}
type NameType = "ChangeDecks";
const name: NameType = "ChangeDecks";
/**
* Check if an action is an change decks action.
* @param action The action to check.
*/
export const is = (action: Action): action is ChangeDecks =>
action.action === name;
export const handle: Handler<ChangeDecks> = (auth, lobby, action) => {
const config = lobby.config;
const version = config.version + 1;
let change: undefined | config.PlayerDriven = undefined;
const deckSource = action.deck;
deckSource.playCode = deckSource.playCode.toUpperCase();
const resolver = sources.limitedResolver(deckSource);
switch (action.change) {
case "Add":
if (
config.decks.find(deck => resolver.equals(deck.source)) === undefined
) {
change = "Add";
config.decks.push({ source: deckSource });
}
break;
case "Remove":
const index = config.decks.findIndex(deck =>
resolver.equals(deck.source)
);
if (index > -1) {
change = "Remove";
config.decks.splice(index, 1);
}
break;
}
if (change === undefined) {
return {};
} else {
config.version = version;
const decksChanged: DecksChanged = {
event: "DecksChanged",
version: version.toString(),
deck: deckSource,
change: change
};
return {
lobby,
events: [event.target(decksChanged)],
tasks: [new LoadDeckSummary(auth.gc, deckSource)]
};
}
};
@@ -0,0 +1,47 @@
import { Action } from "../../../action";
import * as event from "../../../event";
import * as rules from "../../../games/rules";
import { Handler } from "../../handler";
import * as configure from "../configure";
import * as houseRuleChanged from "../../../events/lobby-event/configured/house-rule-changed";
/**
* Set the hand size for the lobby.
*/
export interface ChangeHouseRule extends configure.Base {
action: NameType;
change: rules.Change;
}
type NameType = "ChangeHouseRule";
const name: NameType = "ChangeHouseRule";
/**
* Check if an action is an change decks action.
* @param action The action to check.
*/
export const is = (action: Action): action is ChangeHouseRule =>
action.action === name;
export const handle: Handler<ChangeHouseRule> = (auth, lobby, action) => {
const houseRules = lobby.config.rules.houseRules;
switch (action.change.houseRule) {
case "PackingHeat":
houseRules.packingHeat = action.change.settings;
break;
case "Rando":
houseRules.rando = action.change.settings;
break;
case "Reboot":
houseRules.reboot = action.change.settings;
break;
}
lobby.config.version += 1;
return {
lobby,
events: [
event.target(houseRuleChanged.of(action.change, lobby.config.version))
]
};
};
@@ -0,0 +1,47 @@
import { Action } from "../../../action";
import * as event from "../../../event";
import { Handler } from "../../handler";
import * as configure from "../configure";
/**
* Set the hand size for the lobby.
*/
export interface SetHandSize extends configure.Base {
action: NameType;
/**
* The number of cards in each player's hand.
* @TJS-type integer
* @minimum 3
*/
handSize: number;
}
type NameType = "SetHandSize";
const name: NameType = "SetHandSize";
/**
* Check if an action is an change decks action.
* @param action The action to check.
*/
export const is = (action: Action): action is SetHandSize =>
action.action === name;
export const handle: Handler<SetHandSize> = (auth, lobby, action) => {
const config = lobby.config;
if (config.rules.handSize !== action.handSize) {
config.rules.handSize = action.handSize;
config.version += 1;
return {
lobby,
events: [
event.target({
event: "HandSizeSet",
handSize: action.handSize,
version: config.version.toString()
})
]
};
} else {
return {};
}
};
@@ -0,0 +1,50 @@
import { Action } from "../../../action";
import * as event from "../../../event";
import * as passwordSet from "../../../events/lobby-event/configured/password-set";
import { PasswordSet } from "../../../events/lobby-event/configured/password-set";
import { Handler } from "../../handler";
import * as configure from "../configure";
/**
* Set (or unset) the password for the lobby.
*/
export interface SetPassword extends configure.Base {
action: NameType;
/**
* @maxLength 100
* @minLength 1
*/
password?: string;
}
type NameType = "SetPassword";
const name: NameType = "SetPassword";
/**
* Check if an action is an change decks action.
* @param action The action to check.
*/
export const is = (action: Action): action is SetPassword =>
action.action === name;
export const handle: Handler<SetPassword> = (auth, lobby, action) => {
const config = lobby.config;
if (action.password !== config.password) {
const version = config.version + 1;
const resultEvent: PasswordSet = {
event: "PasswordSet",
version: version.toString()
};
if (action.password !== undefined) {
config.password = action.password;
resultEvent.password = action.password;
} else {
delete config.password;
}
config.version = version;
const events = event.targetByPrivilege(resultEvent, passwordSet.censor);
return { lobby, events: events };
} else {
return {};
}
};
@@ -0,0 +1,50 @@
import { Action } from "../../../action";
import * as event from "../../../event";
import { ScoreLimitSet } from "../../../events/lobby-event/configured/score-limit-set";
import { Handler } from "../../handler";
import * as configure from "../configure";
/**
* (Un)Set the score limit for the lobby.
*/
export interface SetScoreLimit extends configure.Base {
action: NameType;
/**
* The score threshold for the game - when a player hits this they win.
* If not set, then there is end - the game goes on infinitely.
* @TJS-type integer
* @minimum 1
*/
scoreLimit?: number;
}
type NameType = "SetScoreLimit";
const name: NameType = "SetScoreLimit";
/**
* Check if an action is an change decks action.
* @param action The action to check.
*/
export const is = (action: Action): action is SetScoreLimit =>
action.action === name;
export const handle: Handler<SetScoreLimit> = (auth, lobby, action) => {
const config = lobby.config;
if (config.rules.scoreLimit !== action.scoreLimit) {
const version = config.version + 1;
const scoreLimitSet: ScoreLimitSet = {
event: "ScoreLimitSet",
version: version.toString()
};
if (action.scoreLimit !== undefined) {
config.rules.scoreLimit = action.scoreLimit;
scoreLimitSet.scoreLimit = action.scoreLimit;
} else {
delete config.rules.scoreLimit;
}
config.version = version;
return { lobby, events: [event.target(scoreLimitSet)] };
} else {
return {};
}
};
@@ -0,0 +1,27 @@
import { Action } from "../../action";
import { StartGame as StartGameTask } from "../../task/start-game";
import { Handler } from "../handler";
/**
* Start a game in the lobby if possible.
*/
export interface StartGame {
action: NameType;
}
type NameType = "StartGame";
const name: NameType = "StartGame";
export const is = (action: Action): action is StartGame =>
action.action === name;
export const handle: Handler<StartGame> = (auth, lobby, action) => {
// If a game is already started, two uses probably hit the button at the same
// time. Harmless and no correction needed, so just drop it.
if (lobby.game !== undefined) {
return {};
}
return {
tasks: [new StartGameTask(auth.gc, lobby.config.decks.map(s => s.source))]
};
};
+9
View File
@@ -0,0 +1,9 @@
import { Action as ActionType } from "../action";
import { CreateLobby as CreateLobbyType } from "../action/initial/create-lobby";
import { RegisterUser as RegisterUserType } from "../action/initial/register-user";
import { CheckAlive as CheckAliveType } from "../action/initial/check-alive";
export type Action = ActionType;
export type CreateLobby = CreateLobbyType;
export type RegisterUser = RegisterUserType;
export type CheckAlive = CheckAliveType;
@@ -0,0 +1,529 @@
/* eslint-disable */
/* tslint:disable */
// generated by typescript-json-validator
import Ajv = require("ajv");
import { RegisterUser, CreateLobby, Action, CheckAlive } from "./validation";
export const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
format: "fast",
nullable: true,
unicode: true,
uniqueItems: true,
useDefaults: true
});
ajv.addMetaSchema(require("ajv/lib/refs/json-schema-draft-06.json"));
export { RegisterUser, CreateLobby, Action, CheckAlive };
export const Schema = {
$schema: "http://json-schema.org/draft-07/schema#",
definitions: {
Action: {
anyOf: [
{
$ref: "#/definitions/Judge"
},
{
$ref: "#/definitions/Reveal"
},
{
$ref: "#/definitions/Authenticate"
},
{
$ref: "#/definitions/Submit"
},
{
$ref: "#/definitions/TakeBack"
},
{
$ref: "#/definitions/Redraw"
},
{
$ref: "#/definitions/ChangeDecks"
},
{
$ref: "#/definitions/SetHandSize"
},
{
$ref: "#/definitions/SetPassword"
},
{
$ref: "#/definitions/SetScoreLimit"
},
{
$ref: "#/definitions/ChangeHouseRule"
},
{
$ref: "#/definitions/StartGame"
}
]
},
Authenticate: {
defaultProperties: [],
description: "Authenticate with the game.",
properties: {
action: {
$ref: "#/definitions/NameType_2"
},
token: {
$ref: "#/definitions/Token"
}
},
required: ["action", "token"],
type: "object"
},
Cardcast: {
defaultProperties: [],
description: "A source for Cardcast.",
properties: {
playCode: {
$ref: "#/definitions/PlayCode"
},
source: {
enum: ["Cardcast"],
type: "string"
}
},
required: ["playCode", "source"],
type: "object"
},
Change: {
anyOf: [
{
$ref: '#/definitions/ChangeBase<"PackingHeat",PackingHeat>'
},
{
$ref: '#/definitions/ChangeBase<"Rando",Rando>'
},
{
$ref: '#/definitions/ChangeBase<"Reboot",Reboot>'
}
]
},
"ChangeBase.HouseRule": {
$ref: "#/definitions/PackingHeat"
},
"ChangeBase.HouseRule_1": {
$ref: "#/definitions/Rando"
},
"ChangeBase.HouseRule_2": {
$ref: "#/definitions/Reboot"
},
"ChangeBase.Name": {
enum: ["PackingHeat"],
type: "string"
},
"ChangeBase.Name_1": {
enum: ["Rando"],
type: "string"
},
"ChangeBase.Name_2": {
enum: ["Reboot"],
type: "string"
},
'ChangeBase<"PackingHeat",PackingHeat>': {
defaultProperties: [],
properties: {
houseRule: {
$ref: "#/definitions/ChangeBase.Name"
},
settings: {
$ref: "#/definitions/ChangeBase.HouseRule"
}
},
required: ["houseRule"],
type: "object"
},
'ChangeBase<"Rando",Rando>': {
defaultProperties: [],
properties: {
houseRule: {
$ref: "#/definitions/ChangeBase.Name_1"
},
settings: {
$ref: "#/definitions/ChangeBase.HouseRule_1"
}
},
required: ["houseRule"],
type: "object"
},
'ChangeBase<"Reboot",Reboot>': {
defaultProperties: [],
properties: {
houseRule: {
$ref: "#/definitions/ChangeBase.Name_2"
},
settings: {
$ref: "#/definitions/ChangeBase.HouseRule_2"
}
},
required: ["houseRule"],
type: "object"
},
ChangeDecks: {
defaultProperties: [],
description: "Make a change to the configuration of decks for the lobby.",
properties: {
action: {
$ref: "#/definitions/NameType_3"
},
change: {
$ref: "#/definitions/PlayerDriven"
},
deck: {
$ref: "#/definitions/External"
},
if: {
description:
"If the config version doesn't match this, the operation will be rejected.\nThis avoids users accidentally overwriting each other's changes.",
type: "string"
}
},
required: ["action", "change", "deck", "if"],
type: "object"
},
ChangeHouseRule: {
defaultProperties: [],
description: "Set the hand size for the lobby.",
properties: {
action: {
$ref: "#/definitions/NameType_7"
},
change: {
$ref: "#/definitions/Change"
},
if: {
description:
"If the config version doesn't match this, the operation will be rejected.\nThis avoids users accidentally overwriting each other's changes.",
type: "string"
}
},
required: ["action", "change", "if"],
type: "object"
},
CheckAlive: {
defaultProperties: [],
description: "Previously obtained tokens to check the validity of.",
properties: {
tokens: {
items: {
type: "string"
},
type: "array"
}
},
required: ["tokens"],
type: "object"
},
CreateLobby: {
defaultProperties: [],
description: "The details needed to create a new lobby.",
properties: {
name: {
description:
'The name of the lobby, if not given, will default to "Name\'s Game".',
type: "string"
},
owner: {
$ref: "#/definitions/RegisterUser",
description: "The registration for the owner of the lobby."
}
},
required: ["owner"],
type: "object"
},
External: {
$ref: "#/definitions/Cardcast",
description: "A source for Cardcast."
},
Id: {
description: "A unique id for a play.",
format: "uuid",
type: "string"
},
Judge: {
defaultProperties: [],
description: "A user declares the winning play for a round.",
properties: {
action: {
$ref: "#/definitions/NameType"
},
winner: {
$ref: "#/definitions/Id"
}
},
required: ["action", "winner"],
type: "object"
},
Name: {
description: "The name the user goes by.",
maxLength: 100,
minLength: 1,
type: "string"
},
NameType: {
enum: ["Judge"],
type: "string"
},
NameType_1: {
enum: ["Reveal"],
type: "string"
},
NameType_2: {
enum: ["Authenticate"],
type: "string"
},
NameType_3: {
enum: ["ChangeDecks"],
type: "string"
},
NameType_4: {
enum: ["SetHandSize"],
type: "string"
},
NameType_5: {
enum: ["SetPassword"],
type: "string"
},
NameType_6: {
enum: ["SetScoreLimit"],
type: "string"
},
NameType_7: {
enum: ["ChangeHouseRule"],
type: "string"
},
NameType_8: {
enum: ["StartGame"],
type: "string"
},
PackingHeat: {
defaultProperties: [],
description: 'Configuration for the "Packing Heat" house rule.',
type: "object"
},
PlayCode: {
description: "A Cardcast play code for a deck.",
type: "string"
},
PlayerDriven: {
enum: ["Add", "Remove"],
type: "string"
},
Rando: {
defaultProperties: [],
properties: {
number: {
description: "The number of AI players to add to the game.",
maximum: 10,
minimum: 1,
type: "integer"
}
},
required: ["number"],
type: "object"
},
Reboot: {
defaultProperties: [],
description:
'Configuration for the "Reboot the Universe" house rule.\nThis rule allows players to draw a new hand by sacrificing a given number\nof points.',
properties: {
cost: {
description: "The cost to redrawing.",
maximum: 50,
minimum: 1,
type: "integer"
}
},
required: ["cost"],
type: "object"
},
Redraw: {
defaultProperties: [],
description: "A player plays a white card into a round.",
properties: {
action: {
enum: ["Redraw"],
type: "string"
}
},
required: ["action"],
type: "object"
},
RegisterUser: {
defaultProperties: [],
description: "The details to register a new user for a lobby.",
properties: {
name: {
$ref: "#/definitions/Name",
description: "The name the user wishes to use."
},
password: {
description:
"The lobby password, if there is one, this must be given and correct.",
type: "string"
}
},
required: ["name"],
type: "object"
},
Reveal: {
defaultProperties: [],
description: "A user judges the winning play for a round.",
properties: {
action: {
$ref: "#/definitions/NameType_1"
},
play: {
$ref: "#/definitions/Id"
}
},
required: ["action", "play"],
type: "object"
},
SetHandSize: {
defaultProperties: [],
description: "Set the hand size for the lobby.",
properties: {
action: {
$ref: "#/definitions/NameType_4"
},
handSize: {
description: "The number of cards in each player's hand.",
minimum: 3,
type: "integer"
},
if: {
description:
"If the config version doesn't match this, the operation will be rejected.\nThis avoids users accidentally overwriting each other's changes.",
type: "string"
}
},
required: ["action", "handSize", "if"],
type: "object"
},
SetPassword: {
defaultProperties: [],
description: "Set (or unset) the password for the lobby.",
properties: {
action: {
$ref: "#/definitions/NameType_5"
},
if: {
description:
"If the config version doesn't match this, the operation will be rejected.\nThis avoids users accidentally overwriting each other's changes.",
type: "string"
},
password: {
maxLength: 100,
minLength: 1,
type: "string"
}
},
required: ["action", "if"],
type: "object"
},
SetScoreLimit: {
defaultProperties: [],
description: "(Un)Set the score limit for the lobby.",
properties: {
action: {
$ref: "#/definitions/NameType_6"
},
if: {
description:
"If the config version doesn't match this, the operation will be rejected.\nThis avoids users accidentally overwriting each other's changes.",
type: "string"
},
scoreLimit: {
description:
"The score threshold for the game - when a player hits this they win.\nIf not set, then there is end - the game goes on infinitely.",
minimum: 1,
type: "integer"
}
},
required: ["action", "if"],
type: "object"
},
StartGame: {
defaultProperties: [],
description: "Start a game in the lobby if possible.",
properties: {
action: {
$ref: "#/definitions/NameType_8"
}
},
required: ["action"],
type: "object"
},
Submit: {
defaultProperties: [],
description: "A player plays a white card into a round.",
properties: {
action: {
enum: ["Submit"],
type: "string"
},
play: {
items: {
type: "string"
},
type: "array"
}
},
required: ["action", "play"],
type: "object"
},
TakeBack: {
defaultProperties: [],
description: "A player plays a white card into a round.",
properties: {
action: {
enum: ["TakeBack"],
type: "string"
}
},
required: ["action"],
type: "object"
},
Token: {
description: "A token that contains the encoded claims of a user.",
type: "string"
}
}
};
ajv.addSchema(Schema, "Schema");
export function validate(
typeName: "RegisterUser"
): (value: unknown) => RegisterUser;
export function validate(
typeName: "CreateLobby"
): (value: unknown) => CreateLobby;
export function validate(typeName: "Action"): (value: unknown) => Action;
export function validate(
typeName: "CheckAlive"
): (value: unknown) => CheckAlive;
export function validate(typeName: string): (value: unknown) => any {
const validator: any = ajv.getSchema(`Schema#/definitions/${typeName}`);
return (value: unknown): any => {
if (!validator) {
throw new Error(
`No validator defined for Schema#/definitions/${typeName}`
);
}
const valid = validator(value);
if (!valid) {
throw new Error(
"Invalid " +
typeName +
": " +
ajv.errorsText(validator.errors, { dataVar: typeName })
);
}
return value as any;
};
}
+157
View File
@@ -0,0 +1,157 @@
import * as serverConfig from "./config";
import * as decks from "./games/cards/decks";
import * as deckSource from "./games/cards/source";
import * as logging from "./logging";
/**
* A tag is used to check if there is a need to refresh the data in the cache.
*/
export type Tag = string;
/**
* A type that can have a tag attached to indicate freshness.
*/
export interface Tagged {
/**
* A tag which should change if the underlying templates change.
*/
tag?: Tag;
}
/**
* The age of the cached value.
*/
export type Age = number;
/**
* A cached item with it's age.
*/
export interface Aged<T> {
cached: T;
age: Age;
}
/**
* A cache for data that is stored for efficiency, but can be retrieved again.
*/
export abstract class Cache {
/**
* The configuration for this cache.
*/
public abstract readonly config: serverConfig.Cache;
private async get<Always extends Tagged, Sometimes extends Tagged, Result>(
source: deckSource.Resolver,
getCachedAlways: (
source: deckSource.Resolver
) => Promise<Aged<Always> | undefined>,
cacheAlways: (source: deckSource.Resolver, value: Always) => Promise<void>,
cacheSometimes: (
source: deckSource.Resolver,
value: Sometimes
) => Promise<void>,
extract: (result: Result) => [Always, Sometimes | undefined],
miss: () => Promise<Result>
): Promise<Always> {
const cached = await getCachedAlways(source);
if (cached !== undefined && !(await this.cacheExpired(source, cached))) {
return cached.cached;
} else {
const [always, sometimes] = extract(await miss());
await cacheAlways(source, always);
if (sometimes !== undefined) {
cacheSometimes(source, sometimes).catch(error =>
logging.logException("Error while caching:", error)
);
}
return always;
}
}
private async cacheExpired(
source: deckSource.Resolver,
cached: Aged<Tagged>
): Promise<boolean> {
if (
cached.age >= Date.now() + this.config.checkAfter &&
cached.cached.tag !== undefined
) {
return (await source.getTag()) !== cached.cached.tag;
}
return false;
}
/**
* Get the summary from the cache, using the miss function to populate the
* cache if missing.
* @param source The resolver for the source being cached.
* @param miss The function to actively get the summary from the source.
* Note this function can also give the templates if efficient
* to do so.
*/
public async getSummary(
source: deckSource.Resolver,
miss: () => Promise<deckSource.AtLeastSummary>
): Promise<deckSource.Summary> {
return this.get(
source,
s => this.getCachedSummary(s),
(s, summary) => this.cacheSummary(s, summary),
(s, templates: decks.Templates) => this.cacheTemplates(s, templates),
result => [result.summary, result.templates],
miss
);
}
/**
* Get the templates from the cache, using the miss function to populate the
* cache if missing.
* @param source The resolver for the source being cached.
* @param miss The function to actively get the templates from the source.
* Note this function can also give the summary if efficient
* to do so.
*/
public async getTemplates(
source: deckSource.Resolver,
miss: () => Promise<deckSource.AtLeastTemplates>
): Promise<decks.Templates> {
return this.get(
source,
s => this.getCachedTemplates(s),
(s, templates) => this.cacheTemplates(s, templates),
(s, summary: deckSource.Summary) => this.cacheSummary(s, summary),
result => [result.templates, result.summary],
miss
);
}
/**
* Get the given summary from the cache.
*/
public abstract async getCachedSummary(
source: deckSource.Resolver
): Promise<Aged<deckSource.Summary> | undefined>;
/**
* Store the given summary in the cache.
*/
public abstract async cacheSummary(
source: deckSource.Resolver,
summary: deckSource.Summary
): Promise<void>;
/**
* Get the given deck templates from the cache.
*/
public abstract async getCachedTemplates(
source: deckSource.Resolver
): Promise<Aged<decks.Templates> | undefined>;
/**
* Store the given deck templates in the cache.
*/
public abstract async cacheTemplates(
source: deckSource.Resolver,
templates: decks.Templates
): Promise<void>;
}
+13
View File
@@ -0,0 +1,13 @@
import * as serverConfig from "./config";
import { Cache } from "./cache";
import { InMemoryCache } from "./caches/in-memory";
//import { PostgresStore } from "./caches/postgres";
export async function from(config: serverConfig.Cache): Promise<Cache> {
switch (config.type) {
case "InMemory":
return new InMemoryCache(config);
case "PostgreSQL":
throw new Error("Not Implemented");
}
}
+61
View File
@@ -0,0 +1,61 @@
import * as cache from "../cache";
import { Cache } from "../cache";
import * as config from "../config";
import * as decks from "../games/cards/decks";
import * as source from "../games/cards/source";
/**
* An in-memory cache.
*/
export class InMemoryCache extends Cache {
public readonly config: config.InMemoryCache;
private readonly cache: {
summaries: Map<[string, string], cache.Aged<source.Summary>>;
templates: Map<[string, string], cache.Aged<decks.Templates>>;
};
public constructor(config: config.InMemoryCache) {
super();
this.config = config;
this.cache = {
summaries: new Map(),
templates: new Map()
};
}
private static key(source: source.Resolver): [string, string] {
return [source.id(), source.deckId()];
}
public async cacheSummary(
source: source.Resolver,
summary: source.Summary
): Promise<void> {
this.cache.summaries.set(InMemoryCache.key(source), {
cached: summary,
age: Date.now()
});
}
public async cacheTemplates(
source: source.Resolver,
templates: decks.Templates
): Promise<void> {
this.cache.templates.set(InMemoryCache.key(source), {
cached: templates,
age: Date.now()
});
}
public async getCachedSummary(
source: source.Resolver
): Promise<cache.Aged<source.Summary> | undefined> {
return this.cache.summaries.get(InMemoryCache.key(source));
}
public async getCachedTemplates(
source: source.Resolver
): Promise<cache.Aged<decks.Templates> | undefined> {
return this.cache.templates.get(InMemoryCache.key(source));
}
}
+105
View File
@@ -0,0 +1,105 @@
import moment from "moment";
import * as util from "./util";
type Duration = UnparsedDuration | ParsedDuration;
type UnparsedDuration = string;
type ParsedDuration = number;
export interface Config<D extends Duration> {
secret: string;
port: number;
basePath: string;
version: string;
timeouts: Timeouts<D>;
storage: BaseStorage<D>;
cache: BaseCache<D>;
}
export type Parsed = Config<ParsedDuration>;
export type Unparsed = Config<UnparsedDuration>;
type Timeouts<D extends Duration> = {
timeoutCheckFrequency: D;
disconnectionGracePeriod: D;
nextRoundDelay: D;
} & { [key: string]: D };
type BaseStorage<D extends Duration> = BaseInMemory<D> | BasePostgreSQL<D>;
export type Storage = BaseStorage<ParsedDuration>;
export interface PostgreSqlConnection {
host?: string;
port?: number;
user?: string;
database?: string;
password?: string;
keepAlive?: boolean;
}
interface StorageBase<D extends Duration> {
type: string;
garbageCollectionFrequency: D;
}
interface BaseInMemory<D extends Duration> extends StorageBase<D> {
type: "InMemory";
}
export type InMemory = BaseInMemory<ParsedDuration>;
interface BasePostgreSQL<D extends Duration> extends StorageBase<D> {
type: "PostgreSQL";
connection: PostgreSqlConnection;
}
export type PostgreSQL = BasePostgreSQL<ParsedDuration>;
type BaseCache<D extends Duration> =
| BaseInMemoryCache<D>
| BasePostgreSQLCache<D>;
export type Cache = BaseCache<ParsedDuration>;
interface CacheBase<D extends Duration> {
type: string;
checkAfter: D;
}
interface BaseInMemoryCache<D extends Duration> extends CacheBase<D> {
type: "InMemory";
}
export type InMemoryCache = BaseInMemoryCache<ParsedDuration>;
interface BasePostgreSQLCache<D extends Duration> extends CacheBase<D> {
type: "PostgreSQL";
connection: PostgreSqlConnection;
}
export type PostgreSQLCache = BasePostgreSQL<ParsedDuration>;
const parseDuration = (unparsed: UnparsedDuration): ParsedDuration =>
moment.duration(unparsed).asMilliseconds();
export const parseStorage = (
storage: BaseStorage<UnparsedDuration>
): BaseStorage<ParsedDuration> => ({
...storage,
garbageCollectionFrequency: parseDuration(storage.garbageCollectionFrequency)
});
export const parseCache = (
cache: BaseCache<UnparsedDuration>
): BaseCache<ParsedDuration> => ({
...cache,
checkAfter: parseDuration(cache.checkAfter)
});
export const parseTimeouts = (
timeouts: Timeouts<UnparsedDuration>
): Timeouts<ParsedDuration> =>
util.mapObjectValues(timeouts, (key: string, value: UnparsedDuration) =>
parseDuration(value)
);
export const parse = (config: Unparsed): Parsed => ({
...config,
timeouts: parseTimeouts(config.timeouts),
storage: parseStorage(config.storage),
cache: parseCache(config.cache)
});
+15
View File
@@ -0,0 +1,15 @@
/**
* The base for JSON payload for errors.
*/
export interface Details {
error: string;
}
/**
* An error specific to Massive Decks. These can be sent as JSON loads or HTTP
* responses.
*/
export abstract class MassiveDecksError<T extends Details> extends Error {
public abstract readonly status: number;
public abstract details(): T;
}
@@ -0,0 +1,170 @@
import HttpStatus from "http-status-codes";
import { Action } from "../action";
import { GameAction } from "../action/game-action";
import { Privileged } from "../action/privileged";
import * as errors from "../errors";
import * as round from "../games/game/round";
import * as player from "../games/player";
import * as user from "../user";
abstract class ActionExecutionError extends errors.MassiveDecksError<
errors.Details
> {
public readonly status: number = HttpStatus.BAD_REQUEST;
public readonly action: Action;
protected constructor(message: string, action: Action) {
super(message);
this.action = action;
Error.captureStackTrace(this, ActionExecutionError);
}
}
// Could happen if the game ends.
export class GameNotStartedError extends ActionExecutionError {
public constructor(action: GameAction) {
super(
`The game must be started for this action:\n ${JSON.stringify(action)}`,
action
);
Error.captureStackTrace(this, GameNotStartedError);
}
public details = (): errors.Details => ({
error: "GameNotStarted"
});
}
// Could happen if the user has privileges removed.
export class UnprivilegedError extends ActionExecutionError {
public readonly status = HttpStatus.FORBIDDEN;
public constructor(action: Privileged) {
super(
`The user does not have the privilege to perform this action:\n` +
`${JSON.stringify(action)}`,
action
);
Error.captureStackTrace(this, UnprivilegedError);
}
public details = (): errors.Details => ({
error: "Unprivileged"
});
}
interface IncorrectPlayerRoleDetails extends errors.Details {
role: player.Role;
expected: player.Role;
}
// Could happen if the round changes unexpectedly (e.g: czar leaves game).
export class IncorrectPlayerRoleError extends ActionExecutionError {
public readonly role: player.Role;
public readonly expected: player.Role;
public constructor(action: Action, role: player.Role, expected: player.Role) {
super(
`For this action the player must be ${expected} but is ${role}:\n` +
`${JSON.stringify(action)}`,
action
);
this.role = role;
this.expected = expected;
Error.captureStackTrace(this, UnprivilegedError);
}
public details = (): IncorrectPlayerRoleDetails => ({
error: "IncorrectPlayerRole",
role: this.role,
expected: this.expected
});
}
interface IncorrectUserRoleDetails extends errors.Details {
role: user.Role;
expected: user.Role;
}
// Could happen if the user's role changes.
export class IncorrectUserRoleError extends ActionExecutionError {
public readonly role: user.Role;
public readonly expected: user.Role;
public constructor(action: Action, role: user.Role, expected: user.Role) {
super(
`For this action the user must be ${expected} but is ${role}:\n` +
`${JSON.stringify(action)}`,
action
);
this.role = role;
this.expected = expected;
Error.captureStackTrace(this, IncorrectUserRoleError);
}
public details = (): IncorrectUserRoleDetails => ({
error: "IncorrectUserRole",
role: this.role,
expected: this.expected
});
}
interface IncorrectRoundStageDetails extends errors.Details {
stage: round.Stage;
expected: round.Stage;
}
// Could happen if the round changes unexpectedly (e.g: czar leaves game).
export class IncorrectRoundStageError extends ActionExecutionError {
public readonly stage: round.Stage;
public readonly expected: round.Stage;
public constructor(
action: Action,
stage: round.Stage,
expected: round.Stage
) {
super(
`For this action the round must be in the ${expected} stage but is in ` +
`the ${stage} stage:\n ${JSON.stringify(action)}`,
action
);
this.stage = stage;
this.expected = expected;
Error.captureStackTrace(this, IncorrectRoundStageError);
}
public details = (): IncorrectRoundStageDetails => ({
error: "IncorrectRoundStage",
stage: this.stage,
expected: this.expected
});
}
interface ConfigEditConflictDetails extends errors.Details {
version: string;
expected: string;
}
// Could happen if two users edit the configuration at the same time.
export class ConfigEditConflictError extends ActionExecutionError {
public readonly version: string;
public readonly expected: string;
public constructor(action: Action, version: string, expected: string) {
super(
`The configuration is at version ${expected}, but the client's edit ` +
`was made to version ${version}:\n ${JSON.stringify(action)}`,
action
);
this.version = version;
this.expected = expected;
Error.captureStackTrace(this, ConfigEditConflictError);
}
public details = (): ConfigEditConflictDetails => ({
error: "ConfigEditConflict",
version: this.version,
expected: this.expected
});
}
+82
View File
@@ -0,0 +1,82 @@
import HttpStatus from "http-status-codes";
import * as errors from "../errors";
export type Reason =
| "IncorrectIssuer"
| "NotAuthenticated"
| "AlreadyAuthenticated"
| "InvalidAuthentication"
| "InvalidLobbyPassword";
export interface Details extends errors.Details {
error: "AuthenticationFailure";
reason: Reason;
}
abstract class AuthenticationFailureError extends errors.MassiveDecksError<
Details
> {
public readonly status = HttpStatus.FORBIDDEN;
abstract readonly reason: Reason;
protected constructor(reason: string) {
super(`Could not authenticate for the game, ${reason}.`);
Error.captureStackTrace(this, AuthenticationFailureError);
}
public details = (): Details => ({
error: "AuthenticationFailure",
reason: this.reason
});
}
// Could happen if the server database is lost.
export class IncorrectIssuerError extends AuthenticationFailureError {
public readonly reason = "IncorrectIssuer";
public constructor() {
super(
"the authentication was not for this server or the server data store " +
"has been wiped"
);
Error.captureStackTrace(this, IncorrectIssuerError);
}
}
export class NotAuthenticatedError extends AuthenticationFailureError {
public readonly reason = "NotAuthenticated";
public constructor() {
super("the player is not authenticated");
Error.captureStackTrace(this, NotAuthenticatedError);
}
}
export class AlreadyAuthenticatedError extends AuthenticationFailureError {
public readonly reason = "AlreadyAuthenticated";
public constructor() {
super("the player is already authenticated");
Error.captureStackTrace(this, AlreadyAuthenticatedError);
}
}
// Could happen if the server database is lost.
export class InvalidAuthenticationError extends AuthenticationFailureError {
public readonly reason = "InvalidAuthentication";
public constructor(reason: string) {
super(`the given authentication was not valid (${reason})`);
Error.captureStackTrace(this, InvalidAuthenticationError);
}
}
// If the user gets the password wrong.
export class InvalidLobbyPasswordError extends AuthenticationFailureError {
public readonly reason = "InvalidLobbyPassword";
public constructor() {
super("the given lobby password was wrong");
Error.captureStackTrace(this, InvalidLobbyPasswordError);
}
}
+24
View File
@@ -0,0 +1,24 @@
import HttpStatus from "http-status-codes";
import * as errors from "../errors";
abstract class GameStateError extends errors.MassiveDecksError<errors.Details> {
public readonly status: number = HttpStatus.INTERNAL_SERVER_ERROR;
protected constructor(message: string) {
super(message);
Error.captureStackTrace(this, GameStateError);
}
}
// Can happen with calls with large numbers of slots or if the user selects
// decks with very few cards.
export class OutOfCardsError extends GameStateError {
public constructor() {
super("Ran out of cards in the game.");
Error.captureStackTrace(this, OutOfCardsError);
}
public details = (): errors.Details => ({
error: "OutOfCards"
});
}
+51
View File
@@ -0,0 +1,51 @@
import HttpStatus from "http-status-codes";
import * as errors from "../errors";
import { GameCode } from "../lobby/game-code";
export type Reason = "Closed" | "DoesNotExist";
export interface Details extends errors.Details {
reason: Reason;
gameCode: GameCode;
}
// Could happen on user typo.
export abstract class LobbyNotFoundError extends errors.MassiveDecksError<
Details
> {
abstract readonly reason: Reason;
public readonly gameCode: GameCode;
protected constructor(gameCode: GameCode, reason: string) {
super(`The lobby "${gameCode}" could not be found, ${reason}.`);
this.gameCode = gameCode;
Error.captureStackTrace(this, LobbyNotFoundError);
}
public details: () => Details = () => ({
error: "LobbyNotFound",
gameCode: this.gameCode,
reason: this.reason
});
}
// Could happen on user trying to come back to old game.
export class LobbyClosedError extends LobbyNotFoundError {
public readonly status = HttpStatus.GONE;
public readonly reason = "Closed";
public constructor(gameCode: GameCode) {
super(gameCode, "the lobby has been closed");
Error.captureStackTrace(this, LobbyClosedError);
}
}
export class LobbyDoesNotExistError extends LobbyNotFoundError {
public readonly status = HttpStatus.NOT_FOUND;
public readonly reason = "DoesNotExist";
public constructor(gameCode: GameCode) {
super(gameCode, "the lobby never existed");
Error.captureStackTrace(this, LobbyDoesNotExistError);
}
}
+22
View File
@@ -0,0 +1,22 @@
import HttpStatus from "http-status-codes";
import * as errors from "../errors";
export interface Details extends errors.Details {
reason: string;
}
export class InvalidActionError extends errors.MassiveDecksError<Details> {
public readonly status = HttpStatus.BAD_REQUEST;
public readonly reason: string;
public constructor(reason: string) {
super(`Bad request: ${reason}`);
this.reason = reason;
Error.captureStackTrace(this, InvalidActionError);
}
public details: () => Details = () => ({
error: "InvalidAction",
reason: this.reason
});
}
+170
View File
@@ -0,0 +1,170 @@
import WebSocket from "ws";
import wu from "wu";
import { GameEvent } from "./events/game-event";
import { LobbyEvent } from "./events/lobby-event";
import { UserEvent } from "./events/user-event";
import { Game } from "./games/game";
import * as player from "./games/player";
import { Lobby } from "./lobby";
import * as logging from "./logging";
import { SocketManager } from "./socket-manager";
import * as user from "./user";
import { User } from "./user";
import * as util from "./util";
/**
* An event send to clients to update them on the state of the game.
*/
export type Event = LobbyEvent | UserEvent | GameEvent;
/**
* An event with who it should be sent to.
*/
export interface Targeted {
targets?: Set<user.Id> | ((id: user.Id, user: User) => boolean);
event: Event;
}
/**
* Give the event a target of all users in the lobby that fulfil the given
* targeting predicate. If the predicate is not given, it will be sent to all.
* @param event The event.
* @param targets The function that determines if the event will be sent to
* a given user.
*/
export const target = (
event: Event,
targets?: (id: user.Id, user: User) => boolean
): Targeted => ({ targets, event });
/**
* Give the event a target of the given users.
* @param event The event.
* @param targets The users to send the event to.
*/
export const targetSpecifically = (
event: Event,
...targets: user.Id[]
): Targeted => ({ targets: new Set(targets), event });
/**
* Target the given event by privilege. Sending only the event as given to
* privileged users, and optionally a censored version to unprivileged users.
* @param event
* @param censor
*/
export function* targetByPrivilege<T extends Event>(
event: T,
censor?: (event: T) => T
): Iterable<Targeted> {
yield target(event, (_, user) => user.privilege === "Privileged");
if (censor !== undefined) {
yield target(censor(event), (_, user) => user.privilege === "Unprivileged");
}
}
/**
* Target the given event by player role. Sending only the event as given to
* the czar, and optionally a censored version to other players.
* @param game The game at hand.
* @param event The event to send to the czar.
* @param censor The function to censor the event for players.
*/
export function* targetByPlayerRole<T extends Event>(
game: Game,
event: T,
censor?: (event: T) => T
): Iterable<Targeted> {
yield target(event, (id, _) => player.role(game, id) === "Czar");
if (censor !== undefined) {
yield target(event, (id, _) => player.role(game, id) === "Player");
}
}
/**
* Target the given event to only one player, but send a censored version to
* everyone else.
* @param player The player to send the initial event to.
* @param event The event to send.
* @param censor The function to censor the event for others.
*/
export function* targetByPlayer<T extends Event, U extends Event>(
player: user.Id,
event: T,
censor: (event: T) => U
): Iterable<Targeted> {
yield targetSpecifically(event, player);
if (censor !== undefined) {
yield target(event, (id, _) => id !== player);
}
}
function sendHelper(
sockets: Map<user.Id, WebSocket>,
serializedEvent: string,
event: Event
): (user: user.Id) => void {
return user => {
try {
const socket = sockets.get(user);
if (socket) {
socket.send(serializedEvent);
logging.logger.info("WebSocket send:", {
user: user,
event: event
});
}
} catch (error) {}
};
}
/**
* Send the given events to the targets given with them.
* @param sockets The sockets to send with.
* @param lobby The lobby as context to send the events in.
* @param firstEvent The first event to send (there must be at least one).
* @param restEvents Any further events to send.
*/
export function send(
sockets: SocketManager,
lobby: Lobby,
firstEvent: Targeted,
...restEvents: Targeted[]
): void;
/**
* Send the given events to the targets given with them.
* @param sockets The sockets to send with.
* @param lobby The lobby as context to send the events in.
* @param events An iterable of events with targets.
*/
export function send(
sockets: SocketManager,
lobby: Lobby,
events: Iterable<Targeted>
): void;
export function send(
sockets: SocketManager,
lobby: Lobby,
firstEvent: Targeted | Iterable<Targeted>,
...restEvents: Targeted[]
): void {
const events = util.isIterable(firstEvent)
? firstEvent
: [firstEvent, ...restEvents];
for (const event of events) {
const sendToUser = sendHelper(
sockets.sockets,
JSON.stringify(event.event),
event.event
);
const targets = event.targets;
(targets === undefined
? wu(lobby.users.keys())
: targets instanceof Function
? wu(lobby.users.entries())
.filter(([id, user]) => targets(id, user))
.map(([id]) => id)
: targets
).forEach(sendToUser);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { GameStarted } from "./game-event/game-started";
import { HandRedrawn } from "./game-event/hand-redrawn";
import { PlayRevealed } from "./game-event/play-revealed";
import { PlaySubmitted } from "./game-event/play-submitted";
import { PlayTakenBack } from "./game-event/play-taken-back";
import { RoundFinished } from "./game-event/round-finished";
import { RoundStarted } from "./game-event/round-started";
import { StartRevealing } from "./game-event/start-revealing";
export type GameEvent =
| GameStarted
| StartRevealing
| RoundStarted
| RoundFinished
| PlaySubmitted
| PlayRevealed
| PlayTakenBack
| HandRedrawn;
@@ -0,0 +1,20 @@
import * as card from "../../games/cards/card";
import * as publicRound from "../../games/game/round/public";
/**
* Indicated a game has started in the lobby.
*/
export interface GameStarted {
event: "GameStarted";
round: publicRound.Playing;
hand?: card.Response[];
}
export const of = (
round: publicRound.Playing,
hand: card.Response[]
): GameStarted => ({
event: "GameStarted",
round,
hand
});
@@ -0,0 +1,25 @@
import * as card from "../../games/cards/card";
import * as user from "../../user";
/**
* Indicates a player has paid to redraw their hand under the Reboot house rule.
*/
export interface HandRedrawn extends Public {
hand?: card.Response[];
}
export interface Public {
event: "HandRedrawn";
player: user.Id;
}
export const of = (player: user.Id, hand: card.Response[]): HandRedrawn => ({
event: "HandRedrawn",
player,
hand
});
export const censor = (event: HandRedrawn): Public => ({
event: event.event,
player: event.player
});
@@ -0,0 +1,17 @@
import * as play from "../../games/cards/play";
import { Play } from "../../games/cards/play";
/**
* Indicates the czar revealed a play for the round.
*/
export interface PlayRevealed {
event: "PlayRevealed";
id: play.Id;
play: Play;
}
export const of = (id: play.Id, play: Play): PlayRevealed => ({
event: "PlayRevealed",
id,
play
});
@@ -0,0 +1,14 @@
import * as user from "../../user";
/**
* Indicates a player has submitted a play for the round.
*/
export interface PlaySubmitted {
event: "PlaySubmitted";
by: user.Id;
}
export const of = (by: user.Id): PlaySubmitted => ({
event: "PlaySubmitted",
by
});
@@ -0,0 +1,14 @@
import * as user from "../../user";
/**
* Indicates a player has taken back their play for the round.
*/
export interface PlayTakenBack {
event: "PlayTakenBack";
by: user.Id;
}
export const of = (by: user.Id): PlayTakenBack => ({
event: "PlayTakenBack",
by
});
@@ -0,0 +1,18 @@
import * as gameRound from "../../games/game/round";
import * as user from "../../user";
/**
* Indicates players have finished playing into the round and now the czar
* should judge the winner.
*/
export interface RoundFinished {
event: "RoundFinished";
winner: user.Id;
playedBy: { [id: string]: user.Id };
}
export const of = (round: gameRound.Complete): RoundFinished => ({
event: "RoundFinished",
winner: round.winner,
playedBy: gameRound.playedBy(round)
});
@@ -0,0 +1,30 @@
import * as card from "../../games/cards/card";
import * as user from "../../user";
import * as round from "../../games/game/round";
/**
* Indicates a new round has started.
*/
export interface RoundStarted {
event: "RoundStarted";
id: string;
czar: user.Id;
players: user.Id[];
call: card.Call;
drawn?: card.Response[];
}
export const of = (
id: round.Id,
czar: user.Id,
players: user.Id[],
call: card.Call,
drawn?: card.Response[]
): RoundStarted => ({
event: "RoundStarted",
id: id.toString(),
czar,
players,
call,
...(drawn === undefined ? {} : { drawn })
});
@@ -0,0 +1,15 @@
import * as play from "../../games/cards/play";
/**
* Indicates players have finished playing into the round and now the czar
* should reveal the plays.
*/
export interface StartRevealing {
event: "StartRevealing";
plays: play.Id[];
}
export const of = (plays: play.Id[]): StartRevealing => ({
event: "StartRevealing",
plays
});
+5
View File
@@ -0,0 +1,5 @@
import { Configured } from "./lobby-event/configured";
import { ConnectionChanged } from "./lobby-event/connection-changed";
import { PresenceChanged } from "./lobby-event/presence-changed";
export type LobbyEvent = Configured | ConnectionChanged | PresenceChanged;
@@ -0,0 +1,23 @@
import { DecksChanged } from "./configured/decks-changed";
import { HandSizeSet } from "./configured/hand-size-set";
import { HouseRuleChanged } from "./configured/house-rule-changed";
import { PasswordSet } from "./configured/password-set";
import { ScoreLimitSet } from "./configured/score-limit-set";
/**
* An event for when connection state for a user changes.
*/
export type Configured =
| PasswordSet
| HandSizeSet
| ScoreLimitSet
| DecksChanged
| HouseRuleChanged;
export interface Base {
event: string;
/**
* The version the config is at once this change is applied.
*/
version: string;
}
@@ -0,0 +1,23 @@
import * as source from "../../../games/cards/source";
import * as config from "../../../lobby/config";
import * as configured from "../configured";
/**
* A change was made to the configuration of decks for the lobby.
*/
export interface DecksChanged extends configured.Base {
event: "DecksChanged";
deck: source.External;
change: config.DeckChange;
}
export const of = (
deck: source.External,
change: config.DeckChange,
version: config.Version
): DecksChanged => ({
event: "DecksChanged",
deck,
change,
version: version.toString()
});
@@ -0,0 +1,9 @@
import * as configured from "../configured";
/**
* The hand size in the rules for the lobby is changed.
*/
export interface HandSizeSet extends configured.Base {
event: "HandSizeSet";
handSize: number;
}
@@ -0,0 +1,20 @@
import * as config from "../../../lobby/config";
import * as configured from "../configured";
import * as rules from "../../../games/rules";
/**
* A change was made to a house rule in the lobby.
*/
export interface HouseRuleChanged extends configured.Base {
event: "HouseRuleChanged";
change: rules.Change;
}
export const of = (
change: rules.Change,
version: config.Version
): HouseRuleChanged => ({
event: "HouseRuleChanged",
change,
version: version.toString()
});
@@ -0,0 +1,15 @@
import * as configured from "../configured";
/**
* The lobby password is (un)set.
*/
export interface PasswordSet extends configured.Base {
event: "PasswordSet";
password?: string | boolean;
}
export const censor = (passwordSet: PasswordSet): PasswordSet => ({
event: "PasswordSet",
version: passwordSet.version,
password: passwordSet.password !== undefined
});
@@ -0,0 +1,9 @@
import * as configured from "../configured";
/**
* The score limit in the rules for the lobby is (un)set.
*/
export interface ScoreLimitSet extends configured.Base {
event: "ScoreLimitSet";
scoreLimit?: number;
}
@@ -0,0 +1,34 @@
import * as user from "../../user";
/**
* An event for when connection state for a user changes.
*/
export type ConnectionChanged = Connected | Disconnected;
interface Base {
user: user.Id;
}
/**
* A user connects to the lobby.
*/
export interface Connected extends Base {
event: "Connected";
}
export const connected = (user: user.Id): Connected => ({
event: "Connected",
user
});
/**
* A user disconnects from the lobby.
*/
export interface Disconnected extends Base {
event: "Disconnected";
}
export const disconnected = (user: user.Id): Disconnected => ({
event: "Disconnected",
user
});
@@ -0,0 +1,25 @@
import * as user from "../../user";
/**
* An event for when connection state for a user changes.
*/
export type PresenceChanged = Joined | Left;
interface Base {
user: user.Id;
}
/**
* A user connects to the lobby.
*/
export interface Joined extends Base {
event: "Joined";
name: user.Name;
}
/**
* A user disconnects from the lobby.
*/
export interface Left extends Base {
event: "Left";
}
+6
View File
@@ -0,0 +1,6 @@
import { Sync } from "./user-event/sync";
/**
* An event that should be sent to all users.
*/
export type UserEvent = Sync;
+24
View File
@@ -0,0 +1,24 @@
import * as card from "../../games/cards/card";
import { Hand } from "../../games/cards/hand";
import * as lobby from "../../lobby";
/**
* Synchronise the game state.
*/
export interface Sync {
event: "Sync";
state: lobby.Public;
hand?: Hand;
play?: card.Id[];
}
export const of = (
state: lobby.Public,
hand?: Hand,
play?: card.Id[]
): Sync => ({
event: "Sync",
state,
...(hand !== undefined ? { hand } : {}),
...(play !== undefined ? { play } : {})
});
+73
View File
@@ -0,0 +1,73 @@
import { v4 as uuid } from "uuid";
import { Source } from "./source";
import wu = require("wu");
/**
* A game card.
*/
export type Card = Call | Response;
/**
* A call for plays. Some text with blank slots to be filled with responses.
*/
export interface Call extends BaseCard {
/** The text and slots on the call.*/
parts: Part[][];
}
/**
* A response (some text) played into slots.
*/
export interface Response extends BaseCard {
/** The text on the response.*/
text: string;
}
/** A unique id for an instance of a card.*/
export type Id = string;
/** Values shared by all cards.*/
export interface BaseCard {
/** A unique id for a card.*/
id: Id;
/** Where the card came from.*/
source: Source;
}
/** An empty slot for responses to be played into.*/
export interface Slot {
/**
* Defines a transformation over the content the slot is filled with.
*/
transform?: "UpperCase" | "Capitalize";
}
export const isSlot = (part: Part): part is Slot => typeof part !== "string";
/** Either text or a slot.*/
export type Part = string | Slot;
/**
* Create a new user id.
*/
export const id: () => Id = uuid;
/**
* If the given card is a call.
*/
export const isCall = (card: Card): card is Call =>
(card as Call).parts !== undefined;
/**
* If the given card is a response.
*/
export const isResponse = (card: Card): card is Response =>
(card as Response).text !== undefined;
/**
* The number of slots the given call.
*/
export const slotCount = (call: Call): number =>
wu(call.parts)
.flatten(true)
.reduce((count, part) => count + (isSlot(part) ? 1 : 0), 0);
+105
View File
@@ -0,0 +1,105 @@
import * as cache from "../../cache";
import { OutOfCardsError } from "../../errors/game-state-error";
import * as card from "./card";
import * as util from "../../util";
import wu = require("wu");
const union = <T>(sets: Iterable<Set<T>>): Set<T> => {
const result = new Set<T>();
for (const set of sets) {
for (const element of set) {
result.add(element);
}
}
return result;
};
/**
* A deck of cards.
*/
export class Deck<C extends card.BaseCard> {
/**
* The cards in the deck.
*/
public readonly cards: C[];
/**
* Cards drawn from this deck that have since been discarded (used for reshuffling).
*/
public readonly discarded: Set<C>;
public constructor(template: Iterable<Template<C>>) {
this.cards = [];
this.discarded = union(template);
this.reshuffle();
}
public discard(cards: Iterable<C>): void;
public discard(firstCard: C, ...cards: C[]): void;
public discard(firstCard: C | Iterable<C>, ...cards: C[]): void {
const resolvedCards: Iterable<C> = util.isIterable(firstCard)
? cards
: [firstCard, ...cards];
for (const c of resolvedCards) {
this.discarded.add(c);
}
}
public draw(cards: number): C[] {
const cardsLeft = this.cards.length;
const toDraw = Math.min(cardsLeft, cards);
const result = this.cards.splice(0, toDraw);
if (toDraw < cards) {
this.reshuffle();
return [...result, ...this.draw(cards - toDraw)];
} else {
return result;
}
}
public replace(...cards: C[]): C[] {
this.discard(cards);
return this.draw(cards.length);
}
private reshuffle(): void {
if (this.discarded.size < 1) {
throw new OutOfCardsError();
}
this.cards.push(...util.shuffled(this.discarded));
this.discarded.clear();
}
}
/**
* The two decks needed for a game.
*/
export interface Decks {
calls: Deck<card.Call>;
responses: Deck<card.Response>;
}
/**
* A template for a deck.
*/
export type Template<C extends card.BaseCard> = Set<C>;
/**
* Templates for the two decks needed for a game.
*/
export interface Templates extends cache.Tagged {
calls: Template<card.Call>;
responses: Template<card.Response>;
}
/**
* An empty pair of templates.
*/
export const emptyTemplates = (): Templates => ({
calls: new Set(),
responses: new Set()
});
export const decks = (templates: Iterable<Templates>): Decks => ({
calls: new Deck(wu(templates).map(template => template.calls)),
responses: new Deck(wu(templates).map(template => template.responses))
});
+6
View File
@@ -0,0 +1,6 @@
import * as card from "./card";
/**
* A hand of cards.
*/
export type Hand = card.Response[];
+29
View File
@@ -0,0 +1,29 @@
import { v4 as uuid } from "uuid";
import { Response } from "./card";
/** A series of cards played into a round.*/
export type Play = Response[];
/**
* A unique id for a play.
* @TJS-format uuid
*/
export type Id = string;
/**
* An id or id with play.
*/
export interface PotentiallyRevealed {
id: Id;
play?: Play;
}
/**
* A play with its id.
*/
export type Revealed = PotentiallyRevealed & { play: Play };
/**
* Create a new user id.
*/
export const id: () => Id = uuid;
+248
View File
@@ -0,0 +1,248 @@
import * as cache from "../../cache";
import { Cache } from "../../cache";
import * as logging from "../../logging";
import * as decks from "./decks";
import { Cardcast } from "./sources/cardcast";
import { Custom } from "./sources/custom";
/**
* A source for a card or deck.
*/
export type Source = External | Custom;
/**
* An external source for a card or deck.
*/
export type External = Cardcast;
/**
* More information that can be looked up given a source.
*/
export interface Details {
/**
* A name for the source.
*/
name: string;
/**
* A link to more information about the source.
*/
url?: string;
}
export interface Summary extends cache.Tagged {
/**
* Details about the deck.
*/
details: Details;
/**
* The number of calls in the deck.
* @TJS-type integer
*/
calls: number;
/**
* The number of responses in the deck.
* @TJS-type integer
*/
responses: number;
}
export interface AtLeastSummary {
summary: Summary;
templates?: decks.Templates;
}
export interface AtLeastTemplates {
templates: decks.Templates;
summary?: Summary;
}
/**
* Resolve information about the given source.
*/
export abstract class Resolver implements LimitedResolver {
/**
* A unique id for the source as a whole.
*/
public abstract id(): string;
/**
* A unique id for the deck.
*/
public abstract deckId(): string;
/**
* The temporary details to use for the source while loading.
*/
public abstract loadingDetails(): Details;
/**
* If the given source represents the same deck.
*/
public abstract equals(source: External): boolean;
/**
* Go to the remote source to get a tag for the source, if possible.
* Note that if you have a fresh summary, you should check if that has a
* tag first.
*/
public abstract async getTag(): Promise<cache.Tag | undefined>;
/**
* The summary for the source, and potentially the templates if efficient to
* return both.
*/
public abstract async atLeastSummary(): Promise<AtLeastSummary>;
/**
* The summary for the source.
*/
public async summary(): Promise<Summary> {
return (await this.atLeastSummary()).summary;
}
/**
* The details for the source.
*/
public async details(): Promise<Details> {
return (await this.summary()).details;
}
/**
* The deck templates for the source, and potentially the summary if
* efficient to return both.
*/
public abstract async atLeastTemplates(): Promise<AtLeastTemplates>;
/**
* The deck templates for the source.
*/
public async templates(): Promise<decks.Templates> {
return (await this.atLeastTemplates()).templates;
}
/**
* Get both the summary and templates efficiently. This will issue two
* separate requests if necessary, but only one if possible.
*/
public abstract summaryAndTemplates(): {
summary: Promise<Summary>;
templates: Promise<decks.Templates>;
};
}
/**
* A resolver that only allows access to properties that don't require store
* access.
*/
export interface LimitedResolver {
id: () => string;
deckId: () => string;
loadingDetails: () => Details;
equals: (source: External) => boolean;
}
/**
* A resolver that caches expensive responses in the store.
*/
export class CachedResolver extends Resolver {
private readonly resolver: Resolver;
private readonly cache: Cache;
public constructor(cache: Cache, resolver: Resolver) {
super();
this.cache = cache;
this.resolver = resolver;
}
public id(): string {
return this.resolver.id();
}
public deckId(): string {
return this.resolver.deckId();
}
public loadingDetails(): Details {
return this.resolver.loadingDetails();
}
public equals(source: External): boolean {
return this.resolver.equals(source);
}
public async getTag(): Promise<cache.Tag | undefined> {
// Caching the tag here would defy the point.
return this.resolver.getTag();
}
public async atLeastSummary(): Promise<AtLeastSummary> {
return {
summary: await this.cache.getSummary(this.resolver, () =>
this.resolver.atLeastSummary()
)
};
}
public async atLeastTemplates(): Promise<AtLeastTemplates> {
return {
templates: await this.cache.getTemplates(this.resolver, () =>
this.resolver.atLeastTemplates()
)
};
}
public summaryAndTemplates(): {
summary: Promise<Summary>;
templates: Promise<decks.Templates>;
} {
const promise = this.internalSummaryAndTemplates();
return {
summary: promise.then(async value => await value.summary),
templates: promise.then(async value => await value.templates)
};
}
private async internalSummaryAndTemplates(): Promise<{
summary: Promise<Summary>;
templates: Promise<decks.Templates>;
}> {
return this.internalSummaryAndTemplatesContent(
await this.cache.getCachedSummary(this.resolver),
await this.cache.getCachedTemplates(this.resolver)
);
}
private internalSummaryAndTemplatesContent(
cachedSummary?: cache.Aged<Summary>,
cachedTemplates?: cache.Aged<decks.Templates>
): {
summary: Promise<Summary>;
templates: Promise<decks.Templates>;
} {
if (cachedSummary === undefined && cachedTemplates === undefined) {
const result = this.resolver.summaryAndTemplates();
result.summary
.then(async s => await this.cache.cacheSummary(this.resolver, s))
.catch(CachedResolver.logError);
result.templates
.then(async t => await this.cache.cacheTemplates(this.resolver, t))
.catch(CachedResolver.logError);
return result;
} else {
return {
summary:
cachedSummary !== undefined
? Promise.resolve(cachedSummary.cached)
: this.summary(),
templates:
cachedTemplates !== undefined
? Promise.resolve(cachedTemplates.cached)
: this.templates()
};
}
}
private static logError(error: Error): void {
logging.logException("Error while caching:", error);
}
}
+39
View File
@@ -0,0 +1,39 @@
import { Cache } from "../../cache";
import * as deckSource from "./source";
import { Source } from "./source";
import * as cardcast from "./sources/cardcast";
import * as custom from "./sources/custom";
function uncachedResolver(source: deckSource.External): deckSource.Resolver {
switch (source.source) {
case "Cardcast":
return new cardcast.Resolver(source);
}
}
/**
* Get the limited resolver for the given source.
*/
export const limitedResolver = (
source: deckSource.External
): deckSource.LimitedResolver => uncachedResolver(source);
/**
* Get the resolver for the given source.
*/
export const resolver = (
cache: Cache,
source: deckSource.External
): deckSource.Resolver =>
new deckSource.CachedResolver(cache, uncachedResolver(source));
/**
* Get the details for the given source.
*/
export const details = async (
cache: Cache,
source: Source
): Promise<deckSource.Details> =>
source.source === "Custom"
? custom.details(source)
: await resolver(cache, source).details();
@@ -0,0 +1,211 @@
import http, { AxiosRequestConfig } from "axios";
import * as genericPool from "generic-pool";
import * as card from "../card";
import { Slot } from "../card";
import * as decks from "../decks";
import * as source from "../source";
interface CCSummary {
name: string;
code: string;
description: string;
unlisted: boolean;
created_at: string;
updated_at: string;
external_copyright: boolean;
copyright_holder_url: string;
category: string | null;
call_count: string;
response_count: string;
author: {
id: string;
username: string;
};
rating: string;
}
interface CCDeck {
calls: CCCard[];
responses: CCCard[];
}
interface CCCard {
id: string;
text: string[];
created_at: string;
nsfw: boolean;
}
const config: AxiosRequestConfig = {
method: "GET",
baseURL: "https://api.cardcastgame.com/v1/",
timeout: 10000,
responseType: "json"
};
/**
* We pool requests to cardcast to stop us hitting them too hard (on top of
* caching). We only allow two simultaneous requests.
*/
const connectionPool = genericPool.createPool(
{
create: async () => http.create(config),
destroy: async _ => {}
},
{ max: 2 }
);
const summaryUrl = (playCode: PlayCode): string => `decks/${playCode}`;
const deckUrl = (playCode: PlayCode): string => `${summaryUrl(playCode)}/cards`;
const humanViewUrl = (playCode: PlayCode): string =>
`https://www.cardcastgame.com/browse/deck/${playCode}`;
/**
* A source for Cardcast.
*/
export interface Cardcast {
source: "Cardcast";
playCode: PlayCode;
}
/**
* A Cardcast play code for a deck.
*/
export type PlayCode = string;
const nextWordShouldBeCapitalizedRegex = /[.?!]\s*$/;
const nextWordShouldBeCapitalized = (previously: string): boolean =>
previously.match(nextWordShouldBeCapitalizedRegex) !== null;
/**
* Get the parts from Cardcast's representation. As Cardcast doesn't offer as
* much flexibility as we do, we use heuristics to try and do the right thing.
*/
// TODO: We probably want to offer some control over these heuristics.
function* parts(call: CCCard): Iterable<card.Part> {
let upper: Slot = call.text.every(text => text === text.toUpperCase())
? { transform: "UpperCase" }
: {};
let first = true;
let previous: string | null = null;
for (const text of call.text) {
if (previous !== null) {
let capitalize: Slot =
nextWordShouldBeCapitalized(previous) || first
? { transform: "Capitalize" }
: {};
yield { ...capitalize, ...upper };
}
if (text !== "") {
yield text;
}
previous = text;
if (first && (text !== "" || previous !== null)) {
first = false;
}
}
}
const call = (source: Cardcast, call: CCCard): card.Call => ({
id: card.id(),
parts: [Array.from(parts(call))],
source: source
});
const response = (source: Cardcast, response: CCCard): card.Response => ({
id: card.id(),
text: response.text[0],
source: source
});
export class Resolver extends source.Resolver {
public readonly source: Cardcast;
public constructor(source: Cardcast) {
super();
this.source = source;
}
public id(): string {
return "Cardcast";
}
public deckId(): string {
return this.source.playCode;
}
public loadingDetails(): source.Details {
return {
name: `Cardcast ${this.source.playCode}`,
url: humanViewUrl(this.source.playCode)
};
}
public equals(source: source.External): boolean {
return (
source.source === "Cardcast" &&
this.source.playCode.toUpperCase() === source.playCode.toUpperCase()
);
}
public async getTag(): Promise<string | undefined> {
return (await this.summary()).tag;
}
public async atLeastSummary(): Promise<source.AtLeastSummary> {
const summary = (await Resolver.get(
summaryUrl(this.source.playCode)
)) as CCSummary;
try {
return {
summary: {
details: {
name: summary.name,
url: humanViewUrl(this.source.playCode)
},
calls: Number.parseInt(summary.call_count, 10),
responses: Number.parseInt(summary.response_count, 10),
tag: summary.updated_at
}
};
} catch (error) {
// TODO: Error wrapper for unexpected response shape.
throw error;
}
}
public async atLeastTemplates(): Promise<source.AtLeastTemplates> {
const deck = (await Resolver.get(deckUrl(this.source.playCode))) as CCDeck;
try {
return {
templates: {
calls: new Set(deck.calls.map(c => call(this.source, c))),
responses: new Set(deck.responses.map(r => response(this.source, r)))
}
};
} catch (error) {
// TODO: Error wrapper for unexpected response shape.
throw error;
}
}
private static async get<T>(url: string): Promise<object> {
const connection = await connectionPool.acquire();
try {
return (await connection.get(url)).data;
} catch (error) {
// TODO: Error wrapper for connection to Cardcast.
throw error;
} finally {
connectionPool.release(connection);
}
}
public summaryAndTemplates = (): {
summary: Promise<source.Summary>;
templates: Promise<decks.Templates>;
} => ({
summary: this.summary(),
templates: this.templates()
});
}
@@ -0,0 +1,15 @@
import * as source from "../source";
/**
* A source for custom cards made during the game.
*/
export interface Custom {
source: "Custom";
}
/**
* Get the details for a given source.
*/
export const details = (_: Custom): source.Details => ({
name: "Custom"
});
+86
View File
@@ -0,0 +1,86 @@
import * as user from "../user";
import * as util from "../util";
import { Decks } from "./cards/decks";
import * as decks from "./cards/decks";
import * as round from "./game/round";
import { Round } from "./game/round";
import * as publicRound from "./game/round/public";
import { Public as PublicRound } from "./game/round/public";
import * as player from "./player";
import { Player } from "./player";
import * as rules from "./rules";
import { Rules } from "./rules";
import wu from "wu";
/**
* The state of a game.
*/
export interface Game {
round: Round;
history: round.Complete[];
playerOrder: user.Id[];
players: Map<user.Id, Player>;
decks: Decks;
rules: Rules;
winner?: user.Id;
}
export interface Public {
round: PublicRound;
history: publicRound.Complete[];
playerOrder: user.Id[];
players: { [id: string]: player.Public };
rules: rules.Public;
winner?: user.Id;
}
export const censor: (game: Game) => Public = game => ({
round: round.censor(game.round),
history: game.history.map(complete => round.censor(complete)),
playerOrder: game.playerOrder,
players: util.mapObjectValues(util.mapToObject(game.players), player.censor),
rules: rules.censor(game.rules),
...(game.winner === undefined ? {} : { winner: game.winner })
});
const newPlayerForUser = (decks: Decks, rules: Rules): Player => ({
hand: decks.responses.draw(rules.handSize),
control: "Human",
score: 0
});
export const start = (
templates: Iterable<decks.Templates>,
players: Iterable<user.Id>,
rules: Rules
): Game & { round: round.Playing } => {
const gameDecks = decks.decks(templates);
const playerOrder = Array.from(players);
const czar = playerOrder[0];
const playerMap = new Map(
wu(playerOrder).map(id => [id, newPlayerForUser(gameDecks, rules)])
);
const [call] = gameDecks.calls.draw(1);
const playersInRound = new Set(wu(playerOrder).filter(id => id !== czar));
return {
round: {
stage: "Playing",
id: 0,
czar,
players: playersInRound,
call,
plays: []
},
history: [],
playerOrder,
players: playerMap,
decks: gameDecks,
rules
};
};
export const nextCzar = (game: Game): user.Id => {
const current = game.round.czar;
const index = game.playerOrder.findIndex(id => id === current) + 1;
return game.playerOrder[index >= game.playerOrder.length ? 0 : index];
};
+164
View File
@@ -0,0 +1,164 @@
import { Action } from "../../action";
import { IncorrectRoundStageError } from "../../errors/action-execution-error";
import * as user from "../../user";
import * as card from "../cards/card";
import * as play from "../cards/play";
import { Play } from "../cards/play";
import * as publicRound from "./round/public";
import { Public as PublicRound } from "./round/public";
export type Round = Playing | Revealing | Judging | Complete;
export type Stage = "Playing" | "Revealing" | "Judging" | "Complete";
export type Id = number;
interface Base {
stage: Stage;
id: Id;
czar: user.Id;
players: Set<user.Id>;
call: card.Call;
plays: StoredPlay[];
}
export interface Playing extends Base {
stage: "Playing";
}
export interface Revealing extends Base {
stage: "Revealing";
plays: StoredPlay[];
}
export interface Judging extends Base {
stage: "Judging";
plays: RevealedStoredPlay[];
}
export interface Complete extends Base {
stage: "Complete";
plays: RevealedStoredPlay[];
winner: user.Id;
}
export interface StoredPlay {
id: play.Id;
playedBy: user.Id;
play: Play;
revealed: boolean;
}
export type RevealedStoredPlay = StoredPlay & { revealed: true };
function* potentiallyRevealedPlays(
round: Revealing
): Iterable<play.PotentiallyRevealed> {
for (const roundPlay of round.plays) {
const potentiallyRevealed: play.PotentiallyRevealed = { id: roundPlay.id };
if (roundPlay.revealed) {
potentiallyRevealed.play = roundPlay.play;
}
yield potentiallyRevealed;
}
}
function* revealedPlays(round: Judging): Iterable<play.Revealed> {
for (const roundPlay of round.plays) {
yield { id: roundPlay.id, play: roundPlay.play };
}
}
export function playedBy(round: Complete): { [player: string]: user.Id } {
const obj: { [player: string]: user.Id } = {};
for (const roundPlay of round.plays) {
obj[roundPlay.id] = roundPlay.playedBy;
}
return obj;
}
export function playsObj(round: Complete): { [player: string]: Play } {
const obj: { [player: string]: Play } = {};
for (const roundPlay of round.plays) {
obj[roundPlay.playedBy] = roundPlay.play;
}
return obj;
}
export function censor(round: Playing): publicRound.Playing;
export function censor(round: Revealing): publicRound.Revealing;
export function censor(round: Judging): publicRound.Judging;
export function censor(round: Complete): publicRound.Complete;
export function censor(round: Round): PublicRound;
export function censor(round: Round): PublicRound {
switch (round.stage) {
case "Playing":
return {
stage: round.stage,
id: round.id.toString(),
czar: round.czar,
players: Array.from(round.players),
call: round.call,
played: round.plays.map(play => play.playedBy)
};
case "Revealing":
return {
stage: round.stage,
id: round.id.toString(),
czar: round.czar,
players: Array.from(round.players),
call: round.call,
plays: Array.from(potentiallyRevealedPlays(round))
};
case "Judging":
return {
stage: round.stage,
id: round.id.toString(),
czar: round.czar,
players: Array.from(round.players),
call: round.call,
plays: Array.from(revealedPlays(round))
};
case "Complete":
return {
stage: round.stage,
id: round.id.toString(),
czar: round.czar,
players: Array.from(round.players),
call: round.call,
winner: round.winner,
plays: playsObj(round)
};
}
}
/**
* Verifies the user is on the right stage for this action, throwing if they
* are not. Note that you will need to give the generic type explicitly, as
* it.
* @param action
* @param round
* @param expected
*/
export function verifyStage<T extends Round>(
action: Action,
round: Round,
expected: T["stage"]
): round is T {
if (round.stage !== expected) {
throw new IncorrectRoundStageError(action, round.stage, expected);
}
return true;
}
const storedPlayIsRevealed = (play: StoredPlay): play is RevealedStoredPlay =>
play.revealed;
/**
* Checks if every stored play in an array is revealed or not.
*/
export function allStoredPlaysAreRevealed(
round: Round
): round is Round & { plays: RevealedStoredPlay[] } {
return round.plays.every(storedPlayIsRevealed);
}
+35
View File
@@ -0,0 +1,35 @@
import * as user from "../../../user";
import * as card from "../../cards/card";
import { Play } from "../../cards/play";
import * as play from "../../cards/play";
export type Public = Playing | Revealing | Judging | Complete;
interface Base {
stage: string;
id: string;
czar: user.Id;
players: user.Id[];
call: card.Call;
}
export interface Playing extends Base {
stage: "Playing";
played: user.Id[];
}
export interface Revealing extends Base {
stage: "Revealing";
plays: play.PotentiallyRevealed[];
}
export interface Judging extends Base {
stage: "Judging";
plays: play.Revealed[];
}
export interface Complete extends Base {
stage: "Complete";
winner: user.Id;
plays: { [player: string]: Play };
}
+53
View File
@@ -0,0 +1,53 @@
import * as user from "../user";
import { Hand } from "./cards/hand";
import { Game } from "./game";
/**
* A player in the game.
*/
export interface Player {
hand: Hand;
control: Control;
score: Score;
}
/**
* A player containing only state all users can see.
*/
export interface Public {
control: Control;
score: Score;
}
/**
* The role the player currently has in the game.
*/
export type Role = "Czar" | "Player";
/**
* Who controls the player.
*/
export type Control = "Human" | "Computer";
/**
* How many points the player has scored.
* @TJS-type integer
* @minimum 0
*/
export type Score = number;
/**
* Produce a public version of the given player.
*/
export const censor = (player: Player): Public => ({
control: player.control,
score: player.score
});
/**
* Get the given player's role in the game.
* @param game The game.
* @param player The player's id.
*/
export const role = (game: Game, player: user.Id): Role =>
game.round.czar === player ? "Czar" : "Player";
+86
View File
@@ -0,0 +1,86 @@
/** The rules for a standard game.*/
export interface Rules {
/**
* The number of cards in each player's hand.
* @TJS-type integer
* @minimum 3
* @maximum 50
*/
handSize: number;
/**
* The score threshold for the game - when a player hits this they win.
* If not set, then there is end - the game goes on infinitely.
* @TJS-type integer, undefined
* @minimum 1
* @maximum 10000
*/
scoreLimit?: number;
houseRules: HouseRules;
}
export interface Public {
handSize: number;
scoreLimit?: number;
houseRules: HouseRules;
}
export interface HouseRules {
packingHeat?: PackingHeat;
reboot?: Reboot;
rando?: Rando;
}
/**
* Create a default set of rules.
*/
export const create = (): Rules => ({
handSize: 10,
scoreLimit: 25,
houseRules: {}
});
/**
* Configuration for the "Packing Heat" house rule.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PackingHeat {}
/**
* Configuration for the "Reboot the Universe" house rule.
* This rule allows players to draw a new hand by sacrificing a given number
* of points.
*/
export interface Reboot {
/**
* The cost to redrawing.
* @TJS-type integer
* @minimum 1
* @maximum 50
*/
cost: number;
}
export interface Rando {
/**
* The number of AI players to add to the game.
* @TJS-type integer
* @minimum 1
* @maximum 10
*/
number: number;
}
export const censor = (rules: Rules): Public => ({
...rules
});
export interface ChangeBase<Name extends string, HouseRule> {
houseRule: Name;
settings?: HouseRule;
}
export type ChangePackingHeat = ChangeBase<"PackingHeat", PackingHeat>;
export type ChangeRando = ChangeBase<"Rando", Rando>;
export type ChangeReboot = ChangeBase<"Reboot", Reboot>;
export type Change = ChangePackingHeat | ChangeRando | ChangeReboot;
+198
View File
@@ -0,0 +1,198 @@
import bodyParser from "body-parser";
import express, { NextFunction, Request, Response } from "express";
import "express-async-errors";
import expressWinston from "express-winston";
import ws from "express-ws";
import fs from "fs";
import HttpStatus from "http-status-codes";
import JSON5 from "json5";
import sourceMapSupport from "source-map-support";
import { promisify } from "util";
import * as createLobby from "./action/initial/create-lobby";
import * as registerUser from "./action/initial/register-user";
import * as serverConfig from "./config";
import { MassiveDecksError } from "./errors";
import { InvalidLobbyPasswordError } from "./errors/authentication";
import * as event from "./event";
import * as change from "./lobby/change";
import { GameCode } from "./lobby/game-code";
import * as logging from "./logging";
import * as serverState from "./server-state";
import * as timeout from "./timeout";
import * as userDisconnect from "./timeout/user-disconnect";
import * as user from "./user";
import * as token from "./user/token";
import * as checkAlive from "./action/initial/check-alive";
sourceMapSupport.install();
process.on("uncaughtException", function(error) {
logging.logException("Uncaught exception: ", error);
process.exit(1);
});
process.on("unhandledRejection", function(reason, promise) {
logging.logger.error(`Unhandled rejection at ${promise}: ${reason}`);
process.exit(1);
});
async function main(): Promise<void> {
const config = serverConfig.parse(JSON5.parse(
(await promisify(fs.readFile)("config.json5")).toString()
) as serverConfig.Unparsed);
const { app } = ws(express());
app.set('trust proxy', true);
const environment = app.get("env");
if (environment !== "development" && config.secret === "CHANGE ME") {
throw new Error(
"Secret not set - this should never be the case outside of " +
"development. Please set a real random secret value in 'config.json5'."
);
}
const state = await serverState.create(config);
app.use(bodyParser.json());
app.use(
expressWinston.logger({
winstonInstance: logging.logger
})
);
app.get("/api/version", async (req, res) => res.json(config.version));
app.get("/api/games", async (req, res) => {
const result = [];
for await (const summary of state.store.lobbySummaries()) {
result.push(summary);
}
res.json(result);
});
app.post("/api/games", async (req, res) => {
const { gameCode, token } = await state.store.newLobby(
createLobby.validate(req.body),
config.secret
);
res.append(
"Location",
`${req.protocol}://${req.hostname}/${config.basePath}api/games/${gameCode}`
);
res.status(HttpStatus.CREATED).json(token);
});
app.post("/api/games/alive", async (req, res) => {
const result: { [key: string]: boolean } = {};
for (const current of checkAlive.validate(req.body).tokens) {
try {
const claims = token.validate(
current,
await state.store.id(),
state.config.secret
);
result[current] = await state.store.exists(claims.gc);
} catch (error) {
result[current] = false;
}
}
res.json(result);
});
app.post("/api/games/:gameCode", async (req, res) => {
const gameCode: GameCode = req.params.gameCode;
const registration = registerUser.validate(req.body);
const newUser = user.create(registration);
const id = await change.applyAndReturn(state, gameCode, lobby => {
if (lobby.config.password !== registration.password) {
throw new InvalidLobbyPasswordError();
}
const id = lobby.nextUserId.toString();
lobby.nextUserId += 1;
lobby.users.set(id, newUser);
return {
change: {
lobby,
events: [
event.target({ event: "Joined", user: id, name: newUser.name })
],
timeouts: [
{
timeout: userDisconnect.of(id),
after: config.timeouts.disconnectionGracePeriod
}
]
},
returnValue: id
};
});
const claims: token.Claims = {
gc: gameCode,
uid: id,
pvg: "Unprivileged"
};
res.json(token.create(claims, await state.store.id(), config.secret));
});
app.ws("/api/games/:gameCode", async (socket, req) => {
const gameCode = req.params.gameCode;
state.socketManager.add(state, gameCode, socket);
});
app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) {
next(error);
}
if (error instanceof MassiveDecksError) {
logging.logger.warn("Bad request:", error.details());
res.status(error.status).json(error.details());
} else {
next(error);
}
});
app.use(
expressWinston.errorLogger({
winstonInstance: logging.logger,
msg: "{{err.message}}"
})
);
app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) {
next(error);
}
res
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.json({ error: "InternalServerError" });
});
setInterval(async () => {
const lobbies = await state.store.garbageCollect();
if (lobbies > 0) {
logging.logger.info(`Collected ${lobbies} ended/abandoned lobbies.`);
}
}, config.storage.garbageCollectionFrequency);
setInterval(async () => {
await timeout.handle(state);
}, config.timeouts.timeoutCheckFrequency);
state.tasks
.loadFromStore(state)
.catch(error => logging.logException("Error running store tasks:", error));
app.listen(config.port, () =>
logging.logger.info(`Serving on port ${config.port}.`)
);
}
main().catch(error => {
logging.logException("Application exception:", error);
process.exit(1);
});
+131
View File
@@ -0,0 +1,131 @@
import { CreateLobby } from "./action/initial/create-lobby";
import * as game from "./games/game";
import { Game } from "./games/game";
import * as rules from "./games/rules";
import * as config from "./lobby/config";
import { Config } from "./lobby/config";
import { GameCode } from "./lobby/game-code";
import * as user from "./user";
import { User } from "./user";
import * as util from "./util";
import * as token from "./user/token";
/**
* A game lobby.
*/
export interface Lobby {
name: string;
public: boolean;
nextUserId: number;
users: Map<user.Id, User>;
owner: user.Id;
config: Config;
game?: Game;
}
/**
* A lobby with an active game.
*/
export type WithActiveGame = Lobby & { game: Game };
/**
* Return if the given lobby has an active game.
*/
export const hasActiveGame = (lobby: Lobby): lobby is WithActiveGame =>
lobby.game !== undefined;
/**
* A game lobby containing only state all users can see.
*/
export interface Public {
name: string;
public: boolean;
users: { [id: string]: user.Public };
owner: user.Id;
config: config.Public;
game?: game.Public;
}
/**
* The state of a lobby.
*/
export type State = "Playing" | "SettingUp";
/**
* A summary of a lobby.
*/
export interface Summary {
name: string;
gameCode: GameCode;
state: State;
users: { players: number; spectators: number };
}
/**
* Create a config with default values.
*/
export const defaultConfig = (): Config => ({
version: 0,
rules: rules.create(),
decks: []
});
/**
* Create a new lobby.
* @param creation The details of the lobby to create.
*/
export function create(creation: CreateLobby): Lobby {
const id = (0).toString();
return {
name: creation.name ? creation.name : `${creation.owner.name}'s Game`,
public: false,
nextUserId: 1,
users: new Map([[id, user.create(creation.owner, "Privileged")]]),
owner: id,
config: defaultConfig()
};
}
/**
* The state of the given lobby.
*/
export const state = (lobby: Lobby): State =>
lobby.game === null ? "SettingUp" : "Playing";
/**
* Get a summary of the given lobby.
* @param gameCode The game code for the lobby.
* @param lobby The lobby.
*/
export const summary = (gameCode: GameCode, lobby: Lobby): Summary => ({
name: lobby.name,
gameCode: gameCode,
state: state(lobby),
users: util.counts(Object.values(lobby.users), {
players: user.isPlaying,
spectators: user.isSpectating
})
});
/**
* If the given lobby has ended.
*/
export const ended = (lobby: Lobby): boolean =>
lobby.game !== undefined && lobby.game.winner !== undefined;
function usersObj(lobby: Lobby): { [id: string]: user.Public } {
const obj: { [id: string]: user.Public } = {};
for (const [id, lobbyUser] of lobby.users.entries()) {
obj[id] = user.censor(lobbyUser);
}
return obj;
}
export const censor = (lobby: Lobby, auth: token.Claims): Public => ({
name: lobby.name,
public: lobby.public,
users: usersObj(lobby),
owner: lobby.owner,
config: config.censor(lobby.config, auth),
...(lobby.game === undefined ? {} : { game: game.censor(lobby.game) })
});
+122
View File
@@ -0,0 +1,122 @@
import * as event from "../event";
import { Lobby } from "../lobby";
import { ServerState } from "../server-state";
import { Task } from "../task";
import * as timeout from "../timeout";
import { GameCode } from "./game-code";
export interface Change {
lobby?: Lobby;
events?: Iterable<event.Targeted>;
timeouts?: Iterable<timeout.TimeoutAfter>;
tasks?: Iterable<Task>;
}
export type Handler = (lobby: Lobby) => Change;
export type HandlerWithReturnValue<T> = (
lobby: Lobby
) => { change: Change; returnValue: T };
function internalApply(
server: ServerState,
originalLobby: Lobby,
change: Change
): {
lobby?: Lobby;
timeouts?: Iterable<timeout.TimeoutAfter>;
tasks?: Iterable<Task>;
} {
let lobbyUnchanged = change.lobby === undefined;
const lobby: Lobby =
change.lobby !== undefined ? change.lobby : originalLobby;
if (change.events !== undefined) {
event.send(server.socketManager, lobby, change.events);
}
let currentLobby = lobby;
const returnTasks = change.tasks !== undefined ? [...change.tasks] : [];
const futureTimeouts = [];
if (change.timeouts !== undefined) {
for (const timeoutAfter of change.timeouts) {
if (timeoutAfter.after > 0) {
futureTimeouts.push(timeoutAfter);
} else {
const chained = internalApply(
server,
currentLobby,
timeout.handler(server, timeoutAfter.timeout, currentLobby)
);
if (chained.lobby !== undefined) {
lobbyUnchanged = false;
}
currentLobby =
chained.lobby !== undefined ? chained.lobby : currentLobby;
if (chained.timeouts !== undefined) {
futureTimeouts.push(...chained.timeouts);
}
if (chained.tasks !== undefined) {
returnTasks.push(...chained.tasks);
}
}
}
}
const lobbyResult = lobbyUnchanged ? {} : { lobby: currentLobby };
const timeoutResult =
futureTimeouts.length === 0 ? {} : { timeouts: futureTimeouts };
const tasksResult = returnTasks.length === 0 ? {} : { tasks: returnTasks };
return { ...lobbyResult, ...timeoutResult, ...tasksResult };
}
/**
* Apply the given change and return a value.
* @param server The server state.
* @param gameCode The game code for the lobby to apply the change to.
* @param handler The handler that gives the change.
* @param timeoutId If given, the id of the timeout being executed.
*/
export async function applyAndReturn<T>(
server: ServerState,
gameCode: GameCode,
handler: HandlerWithReturnValue<T>,
timeoutId?: timeout.Id
): Promise<T> {
const [tasks, returnValue] = await server.store.writeAndReturn(
gameCode,
lobby => {
const { change, returnValue } = handler(lobby);
const result = internalApply(server, lobby, change);
return {
transaction: {
lobby: result.lobby,
timeouts: result.timeouts,
executedTimeout: timeoutId
},
result: [result.tasks, returnValue]
};
}
);
if (tasks !== undefined) {
for (const task of tasks) {
await server.tasks.enqueue(server, task);
}
}
return returnValue;
}
/**
* Apply the given change.
* @param server The server state.
* @param gameCode The game code for the lobby to apply the change to.
* @param handler The handler that gives the change.
* @param timeoutId If given, the id of the timeout being executed.
*/
export async function apply(
server: ServerState,
gameCode: GameCode,
handler: Handler,
timeoutId?: timeout.Id
): Promise<void> {
await applyAndReturn(server, gameCode, lobby => ({
change: handler(lobby),
returnValue: undefined
}));
}
+75
View File
@@ -0,0 +1,75 @@
import * as source from "../games/cards/source";
import { Rules } from "../games/rules";
import * as rules from "../games/rules";
import * as token from "../user/token";
/**
* Configuration for a lobby.
*/
export interface Config {
version: Version;
rules: Rules;
password?: string;
decks: SummarisedSource[];
}
export type Version = number;
export interface Public {
version: string;
rules: rules.Public;
/**
* @maxLength 100
* @minLength 1
*/
password?: boolean | string;
decks: SummarisedSource[];
}
export interface SummarisedSource {
source: source.External;
summary?: source.Summary;
}
export function censor(config: Config, auth: token.Claims): Public {
const privilegedPart: {} =
auth !== undefined && auth.pvg === "Privileged"
? config.password === undefined
? {}
: { password: config.password }
: { password: config.password !== undefined };
return {
version: config.version.toString(),
rules: rules.censor(config.rules),
decks: config.decks,
...privilegedPart
};
}
/**
* The way in which the decks configuration is changed.
*/
export type DeckChange = PlayerDriven | Load | Fail;
export type PlayerDriven = "Add" | "Remove";
/**
* Signifies the given deck was successfully loaded.
*/
export interface Load {
change: "Load";
summary: source.Summary;
}
/**
* The reason a deck could not be loaded.
*/
export type FailReason = "SourceFailure" | "NotFound";
/**
* Signifies the given deck could not be loaded.
*/
export interface Fail {
change: "Fail";
reason: FailReason;
}
+34
View File
@@ -0,0 +1,34 @@
// noinspection SpellCheckingInspection
import Hashids from "hashids";
/**
* A unique code for a lobby to identify it.
* @minLength 2
*/
export type GameCode = string;
/**
* A numeric id for a lobby. This is presented to the user as a game code.
*/
export type LobbyId = number;
// noinspection SpellCheckingInspection
/**
* Game code methods.
*/
const hashIds = new Hashids(
"massivedecks",
2,
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
);
/**
* Decode a game code to a lobby id.
*/
export const decode = (gameCode: GameCode): LobbyId =>
hashIds.decode(gameCode)[0];
/**
* Decode a lobby id to a game code.
*/
export const encode = (lobbyId: LobbyId): GameCode => hashIds.encode(lobbyId);
+43
View File
@@ -0,0 +1,43 @@
import * as winston from "winston";
const logFormat = winston.format.printf(info => {
const stringRest = JSON.stringify(
{
...info,
level: undefined,
message: undefined,
splat: undefined
},
null,
2
);
if (stringRest !== "{}") {
return `${info.level}: ${info.message}\n${stringRest}`;
} else {
return `${info.level}: ${info.message}`;
}
});
export const logger = winston.createLogger({
transports: [new winston.transports.Console()],
format: winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
logFormat
)
});
const exceptionHandler = new winston.ExceptionHandler(logger);
export const exceptionToMeta = exceptionHandler.getAllInfo.bind(
exceptionHandler
);
export const logException = (
message: string,
error: Error,
data?: string
): void => {
const details = exceptionToMeta(error);
logger.error(message, details === undefined ? details : { details, data });
};
+3
View File
@@ -0,0 +1,3 @@
import crypto from "crypto";
console.log(crypto.randomBytes(256 / 8).toString("hex"));
+30
View File
@@ -0,0 +1,30 @@
import { Cache } from "./cache";
import * as caches from "./caches";
import * as config from "./config";
import { SocketManager } from "./socket-manager";
import { Store } from "./store";
import * as stores from "./store/stores";
import * as backgroundTasks from "./task/tasks";
export interface ServerState {
config: config.Parsed;
store: Store;
cache: Cache;
socketManager: SocketManager;
tasks: backgroundTasks.Queue;
}
export async function create(config: config.Parsed): Promise<ServerState> {
const store = await stores.from(config.storage);
const cache = await caches.from(config.cache);
const socketManager = new SocketManager();
const tasks = new backgroundTasks.Queue();
return {
config,
store,
cache,
socketManager,
tasks
};
}
+150
View File
@@ -0,0 +1,150 @@
import WebSocket from "ws";
import * as action from "./action";
import * as authenticate from "./action/authenticate";
import { MassiveDecksError } from "./errors";
import { NotAuthenticatedError } from "./errors/authentication";
import { InvalidActionError } from "./errors/validation";
import * as event from "./event";
import * as gameLobby from "./lobby";
import * as change from "./lobby/change";
import { GameCode } from "./lobby/game-code";
import * as logging from "./logging";
import { ServerState } from "./server-state";
import * as userDisconnect from "./timeout/user-disconnect";
import * as user from "./user";
import * as token from "./user/token";
import * as sync from "./events/user-event/sync";
export type Sockets = Map<user.Id, WebSocket>;
const parseJson = (raw: string): object => {
try {
return JSON.parse(raw);
} catch (error) {
throw new InvalidActionError(error.message);
}
};
export class SocketManager {
public readonly sockets: Sockets;
public constructor() {
this.sockets = new Map();
}
private readonly errorWSHandler = <T>(
socket: WebSocket,
fn: (data: WebSocket.Data) => Promise<T>
): ((data: WebSocket.Data) => Promise<T | void>) => async data => {
try {
return await fn(data);
} catch (error) {
const dataDescription =
typeof data === "string" ? data : `(${typeof data})`;
if (error instanceof MassiveDecksError) {
const details = error.details();
logging.logger.warn("WebSocket bad request:", {
data: dataDescription,
details,
errorMessage: error.message
});
socket.send(JSON.stringify(details));
} else {
logging.logException("WebSocket error:", error, dataDescription);
socket.send(JSON.stringify({ error: "InternalServerError" }));
socket.close();
}
return;
}
};
public add(server: ServerState, gameCode: GameCode, socket: WebSocket): void {
const sockets = this.sockets;
let auth: token.Claims | null = null;
socket.on("open", async () => {});
socket.on(
"message",
this.errorWSHandler(socket, async data => {
if (typeof data !== "string") {
throw new InvalidActionError("Invalid message.");
}
const validated = action.validate(parseJson(data));
if (auth === null) {
if (validated.action === "Authenticate") {
const knownAuth = await authenticate.handle(
server,
validated,
gameCode
);
auth = knownAuth;
const uid = auth.uid;
sockets.set(auth.uid, socket);
await change.apply(server, auth.gc, lobby => {
let hand = undefined;
let play = undefined;
if (lobby.game !== undefined) {
const player = lobby.game.players.get(uid);
if (player !== undefined) {
hand = player.hand;
}
const potentialPlay = lobby.game.round.plays.find(
play => play.playedBy === uid
);
if (potentialPlay !== undefined) {
play = potentialPlay.play.map(card => card.id);
}
}
const user = lobby.users.get(uid);
if (user === undefined) {
throw new Error(
"User doesn't exist, but we have a socket and auth?"
);
}
user.connection = "Connected";
return {
lobby,
events: [
event.targetSpecifically(
sync.of(gameLobby.censor(lobby, knownAuth), hand, play),
uid
)
]
};
});
logging.logger.info("WebSocket connect:", {
user: auth.uid,
authenticate: validated
});
} else {
throw new NotAuthenticatedError();
}
} else {
const claims = auth;
await change.apply(server, auth.gc, lobby =>
action.handle(claims, lobby, validated, server)
);
logging.logger.info("WebSocket receive:", {
user: auth.uid,
action: validated
});
}
})
);
socket.on("close", async () => {
if (auth) {
const uid = auth.uid;
sockets.delete(uid);
await change.apply(server, auth.gc, lobby => ({
lobby,
timeouts: [
{
timeout: userDisconnect.of(uid),
after: server.config.timeouts.disconnectionGracePeriod
}
]
}));
logging.logger.info("WebSocket disconnect:", { user: auth.uid });
}
});
}
}
+114
View File
@@ -0,0 +1,114 @@
import { CreateLobby } from "./action/initial/create-lobby";
import * as serverConfig from "./config";
import * as lobby from "./lobby";
import { Lobby } from "./lobby";
import { GameCode } from "./lobby/game-code";
import * as timeout from "./timeout";
import { Token } from "./user/token";
/**
* Represents a chunk of data that should be written as a single transaction,
* so if the server was restarted either all are applied or none are.
*/
export interface Transaction {
/**
* New lobby data that should replace what is currently in the store.
*/
lobby?: Lobby;
/**
* If given, timeouts that should be added to the store.
*/
timeouts?: Iterable<timeout.TimeoutAfter>;
/**
* If given, the id of the timeout that this resolves, and so should be
* removed from the store.
*/
executedTimeout?: timeout.Id;
}
export type ReadOnlyTransaction = Transaction & {
lobby?: undefined;
timeouts?: Iterable<timeout.TimeoutAfter & { after: Exclude<number, 0> }>;
};
export abstract class Store {
/**
* The configuration for this store.
*/
public abstract readonly config: serverConfig.Storage;
/**
* A unique id for the store - this *must* be unique to the store, otherwise
* there is a chance reused lobby codes could be incorrectly accessed by users
* using old tokens. When this changes, it makes all previously issued tokens
* invalid, but if there is any security concern, change the application
* secret, not this. That will have the same effect securely.
*/
public abstract async id(): Promise<string>;
/**
* Returns if the given lobby exists.
*/
public abstract async exists(gameCode: GameCode): Promise<boolean>;
/** Create a new lobby.
* @return The game code for the new lobby and the user id for the owner.
*/
public abstract async newLobby(
creation: CreateLobby,
secret: string
): Promise<{ gameCode: GameCode; token: Token }>;
/**
* An operation that doesn't make any changes to the lobby.
* @param gameCode The game code for the lobby to modify.
* @param read The operation to perform.
*/
public async read<T>(
gameCode: GameCode,
read: (lobby: Lobby) => { transaction: ReadOnlyTransaction; result: T }
): Promise<T> {
return this.writeAndReturn(gameCode, read);
}
/**
* Perform a write operation on a lobby.
* If you make changes to the object as given, you must return it.
* @param gameCode The game code for the lobby to modify.
* @param write The operation to perform.
*/
public async write(
gameCode: GameCode,
write: (lobby: Lobby) => Transaction
): Promise<void> {
await this.writeAndReturn(gameCode, (lobby: Lobby) => ({
transaction: write(lobby),
result: undefined
}));
}
public abstract async writeAndReturn<T>(
gameCode: GameCode,
write: (lobby: Lobby) => { transaction: Transaction; result: T }
): Promise<T>;
/** Get a list of summaries for all the public lobbies in the store.*/
public abstract lobbySummaries(): AsyncIterableIterator<lobby.Summary>;
/**
* Get all timed out timeouts from the store.
*/
public abstract timedOut(): AsyncIterableIterator<timeout.TimedOut>;
/**
* Delete the given lobby and all associated timeouts.
*/
public abstract async delete(gameCode: GameCode): Promise<void>;
/**
* Remove lobbies where the game is finished or everyone has been
* disconnected for some time.
* This should also clean up the cache as appropriate.
*/
public abstract async garbageCollect(): Promise<number>;
}
+157
View File
@@ -0,0 +1,157 @@
import { v4 as uuid } from "uuid";
import wu from "wu";
import { CreateLobby } from "../action/initial/create-lobby";
import * as serverConfig from "../config";
import { LobbyClosedError, LobbyDoesNotExistError } from "../errors/lobby";
import * as gameLobby from "../lobby";
import { Lobby } from "../lobby";
import * as lobbyGameCode from "../lobby/game-code";
import { GameCode } from "../lobby/game-code";
import { Store, Transaction } from "../store";
import * as timeout from "../timeout";
import { Timeout } from "../timeout";
import * as token from "../user/token";
import { Token } from "../user/token";
declare module "wu" {
// Fix incorrect types.
interface WuIterable<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spreadMap<U>(fn: (...x: any[]) => U): WuIterable<U>;
}
}
interface TimeoutMeta {
timeout: Timeout;
lobby: GameCode;
after: number;
}
/**
* A store where the data is stored in memory.
*/
export class InMemoryStore extends Store {
public readonly config: serverConfig.InMemory;
private readonly _id: string;
private readonly lobbies: Map<GameCode, Lobby>;
private readonly timeouts: Map<timeout.Id, TimeoutMeta>;
private nextLobby: number;
public constructor(config: serverConfig.InMemory) {
super();
this.config = config;
this._id = uuid();
this.nextLobby = 0;
this.lobbies = new Map();
this.timeouts = new Map();
}
public id = async (): Promise<string> => this._id;
public async exists(gameCode: string): Promise<boolean> {
return this.lobbies.has(gameCode);
}
public async *lobbySummaries(): AsyncIterableIterator<gameLobby.Summary> {
for (const summary of wu(this.lobbies.entries()).spreadMap(
gameLobby.summary
)) {
yield summary;
}
}
public async newLobby(
creation: CreateLobby,
secret: string
): Promise<{ gameCode: GameCode; token: Token }> {
const lobby = gameLobby.create(creation);
const gameCode = lobbyGameCode.encode(this.nextLobby);
this.nextLobby += 1;
this.lobbies.set(gameCode, lobby);
return {
gameCode,
token: token.create(
{
gc: gameCode,
uid: lobby.owner,
pvg: "Privileged"
},
this._id,
secret
)
};
}
private async lobby(gameCode: GameCode): Promise<Lobby> {
const lobby = this.lobbies.get(gameCode.toUpperCase());
if (lobby !== undefined) {
return lobby;
} else {
const lobbyNumber = lobbyGameCode.decode(gameCode);
if (lobbyNumber < this.nextLobby) {
throw new LobbyClosedError(gameCode);
} else {
throw new LobbyDoesNotExistError(gameCode);
}
}
}
public async writeAndReturn<T>(
gameCode: GameCode,
write: (lobby: Lobby) => { transaction: Transaction; result: T }
): Promise<T> {
const { transaction, result } = write(await this.lobby(gameCode));
if (transaction.lobby !== undefined) {
this.lobbies.set(gameCode, transaction.lobby);
}
if (transaction.timeouts !== undefined) {
for (let timeout of transaction.timeouts) {
this.timeouts.set(uuid(), {
timeout: timeout.timeout,
lobby: gameCode,
after: Date.now() + timeout.after
});
}
}
if (transaction.executedTimeout !== undefined) {
this.timeouts.delete(transaction.executedTimeout);
}
return result;
}
public async garbageCollect(): Promise<number> {
const toRemove = new Set<GameCode>();
for (const [gameCode, lobby] of this.lobbies.entries()) {
// TODO: Also lobbies that have been abandoned for some time.
// TODO: Make ended a time, wait for config minutes before deleting.
if (gameLobby.ended(lobby)) {
toRemove.add(gameCode);
}
}
for (const gameCode of toRemove) {
this.delete(gameCode);
}
return toRemove.size;
}
public async delete(gameCode: GameCode): Promise<void> {
this.lobbies.delete(gameCode);
for (const [id, meta] of this.timeouts) {
if (meta.lobby === gameCode) {
this.timeouts.delete(id);
}
}
}
public async *timedOut(): AsyncIterableIterator<timeout.TimedOut> {
for (const [id, meta] of this.timeouts) {
if (Date.now() > meta.after) {
yield {
id,
timeout: meta.timeout,
lobby: meta.lobby
};
}
}
}
}
+146
View File
@@ -0,0 +1,146 @@
import * as genericPool from "generic-pool";
import { Pool } from "generic-pool";
import { Client } from "ts-postgres";
import { CreateLobby } from "../action/initial/create-lobby";
import * as config from "../config";
import * as lobby from "../lobby";
import { Lobby } from "../lobby";
import { GameCode } from "../lobby/game-code";
import * as logging from "../logging";
import * as store from "../store";
import { Store } from "../store";
import * as timeout from "../timeout";
import { Token } from "../user/token";
const idColumn = "id";
const versionColumn = "version";
/**
* A store where the data is stored in a PostgreSQL database.
*/
export class PostgresStore extends Store {
private static readonly version = 0;
public readonly config: config.PostgreSQL;
private readonly pool: Pool<Client>;
private readonly cachedId: string;
public static async create(
config: config.PostgreSQL
): Promise<PostgresStore> {
const client = await this.newClient(config.connection);
try {
const rows = client.query(
`SELECT version, ${idColumn} FROM massivedecks.meta`
);
const row = await rows.one();
const version = row.get(versionColumn);
if (version !== PostgresStore.version) {
// noinspection ExceptionCaughtLocallyJS
throw new Error(
`Database at incorrect version (expected ` +
`"${PostgresStore.version}" got "${version}"). Please upgrade.`
);
}
return new PostgresStore(row.get(idColumn) as string, config);
} catch (error) {
logging.logger.error("Database is not configured correctly.");
throw error;
} finally {
this.destroyClient(client);
}
}
// Only use statically, use the pool instead internally.
private static async newClient(
connection: config.PostgreSqlConnection
): Promise<Client> {
const client = new Client(connection);
await client.connect();
client.on("error", error =>
logging.logger.error("Error from database client: ", error.message)
);
return client;
}
private static async destroyClient(client: Client): Promise<void> {
await client.end();
}
private constructor(id: string, config: config.PostgreSQL) {
super();
this.cachedId = id;
this.config = config;
this.pool = genericPool.createPool(
{
create: async () =>
await PostgresStore.newClient(this.config.connection),
destroy: PostgresStore.destroyClient,
validate: async (client: Client) => !client.closed
},
{ testOnBorrow: true }
);
}
public async id(): Promise<string> {
return this.cachedId;
}
public async exists(gameCode: string): Promise<boolean> {
return false;
}
public async delete(gameCode: string): Promise<void> {
// TODO: Impl.
return;
}
public async garbageCollect(): Promise<number> {
// TODO: Impl.
return 0;
}
public async *lobbySummaries(): AsyncIterableIterator<lobby.Summary> {
// TODO: Caching.
const client = await this.pool.acquire();
try {
return;
} finally {
this.pool.release(client);
}
}
public async newLobby(
creation: CreateLobby,
secret: string
): Promise<{ gameCode: GameCode; token: Token }> {
const client = await this.pool.acquire();
try {
return { gameCode: "", token: "" };
} finally {
this.pool.release(client);
}
}
public async *timedOut(): AsyncIterableIterator<timeout.TimedOut> {
const client = await this.pool.acquire();
try {
return;
} finally {
this.pool.release(client);
}
}
public async writeAndReturn<T>(
gameCode: string,
write: (lobby: Lobby) => { transaction: store.Transaction; result: T }
): Promise<T> {
const client = await this.pool.acquire();
try {
const { result } = write((undefined as unknown) as Lobby);
return result;
} finally {
this.pool.release(client);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import * as serverConfig from "../config";
import { Store } from "../store";
import { InMemoryStore } from "./in-memory";
import { PostgresStore } from "./postgres";
export async function from(config: serverConfig.Storage): Promise<Store> {
switch (config.type) {
case "InMemory":
return new InMemoryStore(config);
case "PostgreSQL":
return PostgresStore.create(config);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Lobby } from "./lobby";
import * as change from "./lobby/change";
import { Change } from "./lobby/change";
import { GameCode } from "./lobby/game-code";
import { ServerState } from "./server-state";
/**
* A good base implementation of a task.
*/
export abstract class TaskBase<T> implements Task {
private readonly gameCode: GameCode;
protected constructor(gameCode: GameCode) {
this.gameCode = gameCode;
}
protected abstract async begin(server: ServerState): Promise<T>;
protected abstract resolve(lobby: Lobby, work: T): Change;
public async handle(server: ServerState): Promise<void> {
const work = await this.begin(server);
await change.apply(server, this.gameCode, lobby =>
this.resolve(lobby, work)
);
}
}
/**
* A task is a long-running process for a lobby.
* It must be discoverable by inspecting the state of the lobby, as they are not
* stored in the store, but will be discovered on restart.
*/
export interface Task {
handle: (server: ServerState) => Promise<void>;
}
+61
View File
@@ -0,0 +1,61 @@
import * as event from "../event";
import * as decksChanged from "../events/lobby-event/configured/decks-changed";
import * as deckSource from "../games/cards/source";
import * as sources from "../games/cards/sources";
import { Lobby } from "../lobby";
import { Change } from "../lobby/change";
import { GameCode } from "../lobby/game-code";
import { ServerState } from "../server-state";
import * as task from "../task";
export class LoadDeckSummary extends task.TaskBase<deckSource.Summary> {
private readonly source: deckSource.External;
public constructor(gameCode: GameCode, source: deckSource.External) {
super(gameCode);
this.source = source;
}
protected async begin(server: ServerState): Promise<deckSource.Summary> {
// We are intentionally ensuring the templates get cached here in advance,
// but don't actually need to do anything with them at this point.
return sources.resolver(server.cache, this.source).summaryAndTemplates()
.summary;
}
protected resolve(lobby: Lobby, work: deckSource.Summary): Change {
const config = lobby.config;
const resolver = sources.limitedResolver(this.source);
const summarised = config.decks.find(deck => resolver.equals(deck.source));
if (summarised !== undefined) {
summarised.summary = work;
config.version += 1;
return {
lobby,
events: [
event.target(
decksChanged.of(
this.source,
{ change: "Load", summary: work },
config.version
)
)
]
};
} else {
return {};
}
}
public static *discover(
gameCode: GameCode,
lobby: Lobby
): Iterable<LoadDeckSummary> {
const config = lobby.config;
for (const deck of config.decks) {
if (deck.summary === undefined) {
yield new LoadDeckSummary(gameCode, deck.source);
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import wu from "wu";
import * as event from "../event";
import * as gameStarted from "../events/game-event/game-started";
import * as decks from "../games/cards/decks";
import * as source from "../games/cards/source";
import * as sources from "../games/cards/sources";
import * as game from "../games/game";
import * as round from "../games/game/round";
import { Lobby } from "../lobby";
import { Change } from "../lobby/change";
import { GameCode } from "../lobby/game-code";
import { ServerState } from "../server-state";
import * as task from "../task";
export class StartGame extends task.TaskBase<decks.Templates[]> {
private readonly decks: Iterable<source.External>;
public constructor(gameCode: GameCode, decks: Iterable<source.External>) {
super(gameCode);
this.decks = decks;
}
protected async begin(server: ServerState): Promise<decks.Templates[]> {
return Promise.all(
wu(this.decks).map(deck =>
sources.resolver(server.cache, deck).templates()
)
);
}
protected resolve(lobby: Lobby, work: decks.Templates[]): Change {
if (lobby.game !== undefined) {
return {};
}
const lobbyGame = game.start(work, lobby.users.keys(), lobby.config.rules);
const gameRound = round.censor(lobbyGame.round);
const events = wu(lobbyGame.players).map(([id, player]) =>
event.targetSpecifically(gameStarted.of(gameRound, player.hand), id)
);
lobby.game = lobbyGame;
return { lobby, events };
}
// This is super unlikely timing-wise, and if it happens, the user just has
// to click start again. They'll live.
public static *discover(
gameCode: GameCode,
lobby: Lobby
): Iterable<StartGame> {}
}
+56
View File
@@ -0,0 +1,56 @@
import { Lobby } from "../lobby";
import { GameCode } from "../lobby/game-code";
import * as logging from "../logging";
import { ServerState } from "../server-state";
import { Task } from "../task";
import { LoadDeckSummary } from "./load-deck-summary";
import { StartGame } from "./start-game";
interface Discoverable {
discover: (gameCode: GameCode, lobby: Lobby) => Iterable<Task>;
}
/**
* A task queue for processing tasks in the background.
*/
export class Queue {
private static readonly tasks: Discoverable[] = [LoadDeckSummary, StartGame];
/**
* Discover tasks that need to be run for the given lobby state.
*/
private static *discover(gameCode: GameCode, lobby: Lobby): Iterable<Task> {
for (const task of Queue.tasks) {
yield* task.discover(gameCode, lobby);
}
}
/**
* Search the store for lobbies and start any tasks necessary given their
* current state.
*/
public async loadFromStore(server: ServerState): Promise<void> {
for await (const { gameCode } of server.store.lobbySummaries()) {
await server.store.read(gameCode, lobby => {
for (const task of Queue.discover(gameCode, lobby)) {
this.enqueue(server, task);
}
return { transaction: {}, result: undefined };
});
}
}
// TODO: Real queuing.
// Probably not a huge deal, but we might go a bit nuts at startup in some
// cases.
/**
* Start a new background task.
*/
public enqueue(server: ServerState, task: Task): void {
logging.logger.info("Task queued:", { task });
task
.handle(server)
.catch(error => logging.logException("Error processing task:", error))
.then(() => logging.logger.info("Task complete:", { task }));
}
}
+63
View File
@@ -0,0 +1,63 @@
import { Lobby } from "./lobby";
import * as change from "./lobby/change";
import { Change } from "./lobby/change";
import { GameCode } from "./lobby/game-code";
import { ServerState } from "./server-state";
import * as finishedPlaying from "./timeout/finished-playing";
import { FinishedPlaying } from "./timeout/finished-playing";
import * as roundStart from "./timeout/round-start";
import { RoundStart } from "./timeout/round-start";
import * as userDisconnect from "./timeout/user-disconnect";
import { UserDisconnect } from "./timeout/user-disconnect";
/**
* A timeout represents something that must happen after a delay in-game.
*/
export type Timeout = UserDisconnect | RoundStart | FinishedPlaying;
export type Id = string;
export interface TimedOut {
id: Id;
timeout: Timeout;
lobby: GameCode;
}
export interface TimeoutAfter {
timeout: Timeout;
after: number;
}
export const handler: Handler<Timeout> = (server, timeout, lobby) => {
switch (timeout.timeout) {
case "UserDisconnect":
return userDisconnect.handle(server, timeout, lobby);
case "RoundStart":
return roundStart.handle(server, timeout, lobby);
case "FinishedPlaying":
return finishedPlaying.handle(server, timeout, lobby);
}
};
/**
* Handle all timeouts in the store that are timed out.
* Note that this will result in writing to the lobby, so it must not be called
* inside another write.
* @param server The server state.
*/
export async function handle(server: ServerState): Promise<void> {
for await (const { id, lobby, timeout } of server.store.timedOut()) {
await change.apply(
server,
lobby,
lobby => handler(server, timeout, lobby),
id
);
}
}
export type Handler<T extends Timeout> = (
server: ServerState,
timeout: T,
lobby: Lobby
) => Change;
+51
View File
@@ -0,0 +1,51 @@
import * as event from "../event";
import * as startRevealing from "../events/game-event/start-revealing";
import * as timeout from "../timeout";
import * as util from "../util";
import * as round from "../games/game/round";
import wu from "wu";
/**
* Indicates that the round should start the judging phase if it is appropriate
* to do so.
*/
export interface FinishedPlaying {
timeout: "FinishedPlaying";
}
const isFinished = (round: round.Playing): boolean => {
const hasPlayed = new Set(wu(round.plays).map(play => play.playedBy));
return util.setEquals(round.players, hasPlayed);
};
export const of = (): FinishedPlaying => ({
timeout: "FinishedPlaying"
});
export const ifNeeded = (playing: round.Playing): FinishedPlaying | undefined =>
isFinished(playing) ? of() : undefined;
export const handle: timeout.Handler<FinishedPlaying> = (
server,
timeout,
lobby
) => {
const game = lobby.game;
if (game === undefined) {
return {};
}
const round = game.round;
const plays = round.plays;
if (round.stage !== "Playing" || !isFinished(round)) {
return {};
}
game.round = {
...round,
stage: "Revealing"
};
const ids = Array.from(wu(plays).map(play => play.id));
return {
lobby,
events: [event.target(startRevealing.of(ids))]
};
};
+69
View File
@@ -0,0 +1,69 @@
import wu from "wu";
import * as event from "../event";
import * as roundStarted from "../events/game-event/round-started";
import * as game from "../games/game";
import * as timeout from "../timeout";
import * as card from "../games/cards/card";
/**
* Indicates that the round should start if it is still appropriate to do so.
*/
export interface RoundStart {
timeout: "RoundStart";
}
export const of = (): RoundStart => ({
timeout: "RoundStart"
});
export const handle: timeout.Handler<RoundStart> = (server, timeout, lobby) => {
const lobbyGame = lobby.game;
if (lobbyGame !== undefined) {
const round = lobbyGame.round;
if (round.stage === "Complete") {
const czar = game.nextCzar(lobbyGame);
const [call] = lobbyGame.decks.calls.replace(round.call);
const slotCount = card.slotCount(call);
const roundId = round.id + 1;
const playersInRound = new Set(
wu(lobbyGame.playerOrder).filter(id => id !== czar)
);
lobbyGame.decks.responses.discard(round.plays.flatMap(play => play.play));
lobbyGame.round = {
stage: "Playing",
id: roundId,
czar: czar,
players: playersInRound,
call: call,
plays: []
};
const playersArray = Array.from(playersInRound);
const basicEvent = roundStarted.of(roundId, czar, playersArray, call);
let events: event.Targeted[];
if (
slotCount > 2 ||
(slotCount === 2 &&
lobbyGame.rules.houseRules.packingHeat !== undefined)
) {
events = [
event.target(basicEvent, (id, user) => user.role !== "Player")
];
const responseDeck = lobbyGame.decks.responses;
for (const [id, player] of lobbyGame.players) {
const extra = responseDeck.draw(slotCount - 1);
player.hand.push(...extra);
events.push(
event.targetSpecifically(
roundStarted.of(roundId, czar, playersArray, call, extra),
id
)
);
}
} else {
events = [event.target(basicEvent)];
}
return { lobby, events };
}
}
return {};
};
+40
View File
@@ -0,0 +1,40 @@
import * as event from "../event";
import * as connectionChanged from "../events/lobby-event/connection-changed";
import * as timeout from "../timeout";
import * as user from "../user";
/**
* Indicates that the user should be marked as disconnected if they still are.
*/
export interface UserDisconnect {
timeout: "UserDisconnect";
user: user.Id;
}
export const of = (user: user.Id): UserDisconnect => ({
timeout: "UserDisconnect",
user
});
export const handle: timeout.Handler<UserDisconnect> = (
server,
timeout,
lobby
) => {
const id = timeout.user;
const socket = server.socketManager.sockets.get(id);
if (socket === undefined) {
const userData = lobby.users.get(id);
if (userData === undefined) {
throw new Error("Player not in lobby.");
}
if (userData.connection !== "Disconnected") {
userData.connection = "Disconnected";
return {
lobby,
events: [event.target(connectionChanged.disconnected(id))]
};
}
}
return {};
};
+94
View File
@@ -0,0 +1,94 @@
import { RegisterUser } from "./action/initial/register-user";
/** A user in a lobby.*/
export interface User {
name: Name;
presence: Presence;
connection: Connection;
privilege: Privilege;
role: Role;
}
/**
* A user containing only state all users can see.
*/
export interface Public {
name: Name;
presence: Presence;
connection: Connection;
privilege: Privilege;
role: Role;
}
/**
* A unique id for a user.
*/
export type Id = string;
/**
* The name the user goes by.
* @maxLength 100
* @minLength 1
*/
export type Name = string;
/**
* The level of privilege a user has.
*/
export type Privilege = "Privileged" | "Unprivileged";
/**
* If the user is currently in the lobby.
*/
export type Presence = "Joined" | "Left";
/**
* If the user is connected to the game at this moment.
*/
export type Connection = "Connected" | "Disconnected";
/**
* If the user is a spectator or a player.
*/
export type Role = "Spectator" | "Player";
/**
* If the user is playing.
*/
export const isPlaying: (user: User) => boolean = user =>
user.role === "Player";
/**
* If the user is spectating.
*/
export const isSpectating: (user: User) => boolean = user =>
user.role === "Spectator";
/**
* Create a new user.
* @param registration The details of the user to create.
* @param privilege The level of privilege the user has.
*/
export function create(
registration: RegisterUser,
privilege: Privilege = "Unprivileged"
): User {
return {
name: registration.name,
presence: "Joined",
connection: "Connected",
privilege: privilege,
role: "Player"
};
}
/**
* Gives a version of the user with only publicly visible properties.
*/
export const censor: (user: User) => Public = user => ({
name: user.name,
presence: user.presence,
connection: user.connection,
privilege: user.privilege,
role: user.role
});
+64
View File
@@ -0,0 +1,64 @@
import * as jwt from "jsonwebtoken";
import {
IncorrectIssuerError,
InvalidAuthenticationError
} from "../errors/authentication";
import { GameCode } from "../lobby/game-code";
import * as user from "../user";
/**
* A token that contains the encoded claims of a user.
*/
export type Token = string;
/**
* The information needed to authorize a user.
* This is encoded into a JWT, signed so the information can be taken from the
* user and verified without storing locally.
*/
export interface Claims {
/**
* The game code for the lobby this claim is valid in.
*/
gc: GameCode;
uid: user.Id;
pvg: user.Privilege;
}
/**
* Make a signed token from some claims.
* @param tokenClaims The claims.
* @param issuer The store these claims are valid in.
* @param secret The secret to sign the claims.
*/
export const create = (
tokenClaims: Claims,
issuer: string,
secret: string
): Token =>
jwt.sign(tokenClaims, secret, { algorithm: "HS256", issuer: issuer });
/**
* Verify the given token and return the claims encoded in it, if valid.
* Note this does *not* validate the game code in the claim.
* @param token The token to validate.
* @param issuer The store we are checking for.
* @param secret The secret to verify the signature on the token.
*/
export function validate(token: Token, issuer: string, secret: string): Claims {
try {
return jwt.verify(token, secret, {
algorithms: ["HS256"],
issuer: issuer
}) as Claims;
} catch (error) {
if (error.hasOwnProperty("name") && error.name === "JsonWebTokenError") {
if (error.message.startsWith("jwt issuer invalid.")) {
throw new IncorrectIssuerError();
} else if (error.message.startsWith("invalid signature")) {
throw new InvalidAuthenticationError("invalid signature");
}
}
throw error;
}
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Check if the given value is an iterable one.
*/
export const isIterable = <T extends object>(
object: T | Iterable<T>
): object is Iterable<T> =>
object !== null &&
typeof (object as Iterable<T>)[Symbol.iterator] === "function";
const isSingleArgument = <A, B, C>(
f: ((a: A, b: B) => C) | ((b: B) => C)
): f is (b: B) => C => f.length === 1;
/**
* Take an object and apply the given function to each value, producing a new
* object.
*/
export function mapObjectValues<O extends { [key: string]: V }, V, U>(
obj: O,
f: ((key: string, value: V) => U) | ((value: V) => U)
): { [P in keyof O]: U } {
const newObj: { [key: string]: U } = {};
for (const [key, value] of Object.entries(obj)) {
newObj[key] = isSingleArgument(f) ? f(value) : f(key, value);
}
return newObj as { [P in keyof O]: U };
}
/**
* Create an object from the given entries.
*/
export function entriesToObject<T>(
entries: Iterable<[string, T]>
): { [key: string]: T } {
const obj: { [key: string]: T } = {};
for (const [key, value] of entries) {
obj[key] = value;
}
return obj;
}
/**
* Create an object from the given map.
*/
export const mapToObject = <T>(map: Map<string, T>): { [key: string]: T } =>
entriesToObject(map);
/**
* Count the number of elements in the given iterable that conform to the given
* predicates. Returns the counts in an object matching the shape of the
* predicates.
* @param iterable The iterable to count the values of.
* @param predicates The predicates to apply to each value to check if it should
* be counted.
*/
export function counts<T, U>(
iterable: Iterable<T>,
predicates: { [P in keyof U]: ((value: T) => boolean) }
): { [P in keyof U]: number } {
const keys = Object.keys(predicates) as (keyof U)[];
const amounts = mapObjectValues(predicates, () => 0);
for (const value of iterable) {
for (const key of keys) {
if (predicates[key](value)) {
amounts[key] += 1;
}
}
}
return amounts;
}
/**
* This will only be valid when the given outcome is impossible. Useful for
* exhaustiveness checks.
* @param value The impossible value.
*/
export function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}.`);
}
/**
* Find the first value that satisfies the given predicate in the given iterable,
* allowing for type assertion.
* @param iterable The iterable.
* @param predicate THe predicate.
*/
export function findIs<T, U extends T>(
iterable: Iterable<T>,
predicate: (item: T) => item is U
): U | undefined {
for (const item of iterable) {
if (predicate(item)) {
return item;
}
}
return undefined;
}
/**
* Shuffle the given array in-place.
* @param items The array.
*/
export function shuffle<T>(items: T[]): void {
for (let index = items.length - 1; index > 0; index -= 1) {
const random = Math.floor(Math.random() * (index + 1));
[items[index], items[random]] = [items[random], items[index]];
}
}
/**
* Return a shuffled array of the given elements.
* @param items The items to shuffle.
*/
export function shuffled<T>(items: Iterable<T>): T[] {
const result = Array.from(items);
shuffle(result);
return result;
}
/**
* If both sets contain the same (and only the same) values.
*/
export function setEquals<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size !== b.size) {
return false;
} else {
for (const value of a) {
if (!b.has(value)) {
return false;
}
}
return true;
}
}