Initial v2
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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))]
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Sync } from "./user-event/sync";
|
||||
|
||||
/**
|
||||
* An event that should be sent to all users.
|
||||
*/
|
||||
export type UserEvent = Sync;
|
||||
@@ -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 } : {})
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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))
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import * as card from "./card";
|
||||
|
||||
/**
|
||||
* A hand of cards.
|
||||
*/
|
||||
export type Hand = card.Response[];
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) })
|
||||
});
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
console.log(crypto.randomBytes(256 / 8).toString("hex"));
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> {}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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))]
|
||||
};
|
||||
};
|
||||
@@ -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 {};
|
||||
};
|
||||
@@ -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 {};
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user