Add a built-in deck source and the CaH base deck.

This does a lot of the work needed for #120.
This commit is contained in:
Gareth Latty
2020-05-01 01:12:12 +01:00
parent 3024b9430a
commit 17331589f0
52 changed files with 2450 additions and 301 deletions
+6 -2
View File
@@ -18,6 +18,7 @@ import { LoadDeckSummary } from "../../task/load-deck-summary";
import * as Handler from "../handler";
import { Privileged } from "../privileged";
import * as Validation from "../validation.validator";
import { ServerState } from "../../server-state";
/**
* An action to change the configuration of the lobby.
@@ -80,6 +81,7 @@ function applyRules(
}
function apply(
server: ServerState,
gameCode: GameCode,
lobby: Lobby.Lobby,
existing: Config.Config,
@@ -92,7 +94,7 @@ function apply(
);
const allTasks = [...tasks];
for (const deck of updated.decks) {
const resolver = Sources.limitedResolver(deck.source);
const resolver = server.sources.limitedResolver(deck.source);
const matching = existing.decks.find((ed) => resolver.equals(ed.source));
if (matching === undefined) {
allTasks.push(new LoadDeckSummary(gameCode, deck.source));
@@ -146,7 +148,8 @@ class ConfigureActions extends Actions.Implementation<
protected handle: Handler.Custom<Configure, Lobby.Lobby> = (
auth,
lobby,
action
action,
server
) => {
const version = lobby.config.version;
for (const op of action.change) {
@@ -163,6 +166,7 @@ class ConfigureActions extends Actions.Implementation<
}
validated = _validateConfig(patched);
const { result, events, tasks } = apply(
server,
auth.gc,
lobby,
lobby.config,
+25 -2
View File
@@ -141,6 +141,22 @@ export const Schema = {
required: ["action", "token"],
type: "object",
},
BuiltIn: {
additionalProperties: false,
defaultProperties: [],
description: "A source for built-in decks..",
properties: {
id: {
type: "string",
},
source: {
enum: ["BuiltIn"],
type: "string",
},
},
required: ["id", "source"],
type: "object",
},
Cardcast: {
additionalProperties: false,
defaultProperties: [],
@@ -295,8 +311,15 @@ export const Schema = {
type: "object",
},
External: {
$ref: "#/definitions/Cardcast",
description: "A source for Cardcast.",
anyOf: [
{
$ref: "#/definitions/BuiltIn",
},
{
$ref: "#/definitions/Cardcast",
},
],
description: "An external source for a card or deck.",
},
FailReason: {
description: "The reason a deck could not be loaded.",
+21 -18
View File
@@ -40,13 +40,16 @@ export abstract class Cache {
*/
public abstract readonly config: ServerConfig.Cache;
private async get<Always extends Tagged, Sometimes extends Tagged, Result>(
source: Source.Resolver,
getCachedAlways: (
source: Source.Resolver
) => Promise<Aged<Always> | undefined>,
cacheAlways: (source: Source.Resolver, value: Always) => void,
cacheSometimes: (source: Source.Resolver, value: Sometimes) => void,
private async get<
Always extends Tagged,
Sometimes extends Tagged,
Resolver extends Source.Resolver<Source.External>,
Result
>(
source: Resolver,
getCachedAlways: (source: Resolver) => Promise<Aged<Always> | undefined>,
cacheAlways: (source: Resolver, value: Always) => void,
cacheSometimes: (source: Resolver, value: Sometimes) => void,
extract: (result: Result) => [Always, Sometimes | undefined],
miss: () => Promise<Result>
): Promise<Always> {
@@ -64,7 +67,7 @@ export abstract class Cache {
}
private async cacheExpired(
source: Source.Resolver,
source: Source.Resolver<Source.External>,
cached: Aged<Tagged>
): Promise<boolean> {
if (
@@ -85,7 +88,7 @@ export abstract class Cache {
* to do so.
*/
public async getSummary(
source: Source.Resolver,
source: Source.Resolver<Source.External>,
miss: () => Promise<Source.AtLeastSummary>
): Promise<Source.Summary> {
return this.get(
@@ -93,7 +96,7 @@ export abstract class Cache {
this.getCachedSummary.bind(this),
this.cacheSummaryBackground.bind(this),
this.cacheTemplatesBackground.bind(this),
result => [result.summary, result.templates],
(result) => [result.summary, result.templates],
miss
);
}
@@ -107,7 +110,7 @@ export abstract class Cache {
* to do so.
*/
public async getTemplates(
source: Source.Resolver,
source: Source.Resolver<Source.External>,
miss: () => Promise<Source.AtLeastTemplates>
): Promise<Decks.Templates> {
return this.get(
@@ -115,7 +118,7 @@ export abstract class Cache {
this.getCachedTemplates.bind(this),
this.cacheTemplatesBackground.bind(this),
this.cacheSummaryBackground.bind(this),
result => [result.templates, result.summary],
(result) => [result.templates, result.summary],
miss
);
}
@@ -124,19 +127,19 @@ export abstract class Cache {
* Get the given summary from the cache.
*/
public abstract async getCachedSummary(
source: Source.Resolver
source: Source.Resolver<Source.External>
): Promise<Aged<Source.Summary> | undefined>;
/**
* Store the given summary in the cache.
*/
public abstract async cacheSummary(
source: Source.Resolver,
source: Source.Resolver<Source.External>,
summary: Source.Summary
): Promise<void>;
public cacheSummaryBackground(
source: Source.Resolver,
source: Source.Resolver<Source.External>,
summary: Source.Summary
): void {
this.cacheSummary(source, summary).catch(Cache.logError);
@@ -146,19 +149,19 @@ export abstract class Cache {
* Get the given deck templates from the cache.
*/
public abstract async getCachedTemplates(
source: Source.Resolver
source: Source.Resolver<Source.External>
): Promise<Aged<Decks.Templates> | undefined>;
/**
* Store the given deck templates in the cache.
*/
public abstract async cacheTemplates(
source: Source.Resolver,
source: Source.Resolver<Source.External>,
templates: Decks.Templates
): Promise<void>;
public cacheTemplatesBackground(
source: Source.Resolver,
source: Source.Resolver<Source.External>,
templates: Decks.Templates
): void {
this.cacheTemplates(source, templates).catch(Cache.logError);
+10 -8
View File
@@ -18,42 +18,44 @@ export class InMemoryCache extends Cache.Cache {
this.config = config;
this.cache = {
summaries: new Map(),
templates: new Map()
templates: new Map(),
};
}
private static key(source: Source.Resolver): [string, string] {
private static key(
source: Source.Resolver<Source.External>
): [string, string] {
return [source.id(), source.deckId()];
}
public async cacheSummary(
source: Source.Resolver,
source: Source.Resolver<Source.External>,
summary: Source.Summary
): Promise<void> {
this.cache.summaries.set(InMemoryCache.key(source), {
cached: summary,
age: Date.now()
age: Date.now(),
});
}
public async cacheTemplates(
source: Source.Resolver,
source: Source.Resolver<Source.External>,
templates: Decks.Templates
): Promise<void> {
this.cache.templates.set(InMemoryCache.key(source), {
cached: templates,
age: Date.now()
age: Date.now(),
});
}
public async getCachedSummary(
source: Source.Resolver
source: Source.Resolver<Source.External>
): Promise<Cache.Aged<Source.Summary> | undefined> {
return this.cache.summaries.get(InMemoryCache.key(source));
}
public async getCachedTemplates(
source: Source.Resolver
source: Source.Resolver<Source.External>
): Promise<Cache.Aged<Decks.Templates> | undefined> {
return this.cache.templates.get(InMemoryCache.key(source));
}
+4 -4
View File
@@ -97,7 +97,7 @@ export class PostgresCache extends Cache.Cache {
}
public async cacheSummary(
source: Source.Resolver,
source: Source.Resolver<Source.External>,
summary: Source.Summary
): Promise<void> {
await this.pg.inTransaction(async (client) => {
@@ -133,7 +133,7 @@ export class PostgresCache extends Cache.Cache {
}
public async cacheTemplates(
source: Source.Resolver,
source: Source.Resolver<Source.External>,
templates: Decks.Templates
): Promise<void> {
await this.pg.inTransaction(async (client) => {
@@ -174,7 +174,7 @@ export class PostgresCache extends Cache.Cache {
}
public async getCachedSummary(
source: Source.Resolver
source: Source.Resolver<Source.External>
): Promise<Cache.Aged<Source.Summary> | undefined> {
return await this.pg.withClient(async (client) => {
const result = await client.query(
@@ -204,7 +204,7 @@ export class PostgresCache extends Cache.Cache {
}
public async getCachedTemplates(
source: Source.Resolver
source: Source.Resolver<Source.External>
): Promise<Cache.Aged<Decks.Templates> | undefined> {
return await this.pg.withClient(async (client) => {
const about = await client.query(
+36 -5
View File
@@ -10,7 +10,7 @@ const environmental: (keyof EnvironmentalConfig)[] = [
"listenOn",
"basePath",
"version",
"touchOnStart"
"touchOnStart",
];
export interface EnvironmentalConfig {
@@ -22,6 +22,7 @@ export interface EnvironmentalConfig {
}
export interface Config<D extends Duration> extends EnvironmentalConfig {
sources: BaseSources<D>;
timeouts: Timeouts<D>;
tasks: Tasks<D>;
storage: BaseStorage<D>;
@@ -42,6 +43,23 @@ type Tasks<D extends Duration> = {
processTickFrequency: D;
};
export interface BuiltIn {
basePath: string;
decks: string[];
}
interface BaseCardcast<D extends Duration> {
timeout: D;
simultaneousConnections: number;
}
export type Cardcast = BaseCardcast<ParsedDuration>;
interface BaseSources<D extends Duration> {
builtIn?: BuiltIn;
cardcast?: BaseCardcast<D>;
}
export type Sources = BaseSources<ParsedDuration>;
type BaseStorage<D extends Duration> = BaseInMemory<D> | BasePostgreSQL<D>;
export type Storage = BaseStorage<ParsedDuration>;
@@ -100,14 +118,14 @@ export const parseStorage = (
): BaseStorage<ParsedDuration> => ({
...storage,
abandonedTime: parseDuration(storage.abandonedTime),
garbageCollectionFrequency: parseDuration(storage.garbageCollectionFrequency)
garbageCollectionFrequency: parseDuration(storage.garbageCollectionFrequency),
});
export const parseCache = (
cache: BaseCache<UnparsedDuration>
): BaseCache<ParsedDuration> => ({
...cache,
checkAfter: parseDuration(cache.checkAfter)
checkAfter: parseDuration(cache.checkAfter),
});
export const parseTimeouts = (
@@ -121,7 +139,19 @@ export const parseTasks = (
tasks: Tasks<UnparsedDuration>
): Tasks<ParsedDuration> => ({
...tasks,
processTickFrequency: parseDuration(tasks.processTickFrequency)
processTickFrequency: parseDuration(tasks.processTickFrequency),
});
const parseCardcast = (cardcast: BaseCardcast<UnparsedDuration>): Cardcast => ({
...cardcast,
timeout: parseDuration(cardcast.timeout),
});
const parseSources = (sources: BaseSources<UnparsedDuration>): Sources => ({
...(sources.builtIn !== undefined ? { builtIn: sources.builtIn } : {}),
...(sources.cardcast !== undefined
? { cardcast: parseCardcast(sources.cardcast) }
: {}),
});
export const pullFromEnvironment = (config: Parsed): Parsed => {
@@ -141,8 +171,9 @@ export const pullFromEnvironment = (config: Parsed): Parsed => {
export const parse = (config: Unparsed): Parsed =>
pullFromEnvironment({
...config,
sources: parseSources(config.sources),
timeouts: parseTimeouts(config.timeouts),
tasks: parseTasks(config.tasks),
storage: parseStorage(config.storage),
cache: parseCache(config.cache)
cache: parseCache(config.cache),
});
+49 -6
View File
@@ -6,6 +6,7 @@ import * as Errors from "../errors";
import * as Round from "../games/game/round";
import * as Player from "../games/player";
import * as User from "../user";
import * as Source from "../games/cards/source";
abstract class ActionExecutionError extends Errors.MassiveDecksError<
Errors.Details
@@ -31,7 +32,7 @@ export class GameNotStartedError extends ActionExecutionError {
}
public details = (): Errors.Details => ({
error: "GameNotStarted"
error: "GameNotStarted",
});
}
@@ -49,7 +50,7 @@ export class UnprivilegedError extends ActionExecutionError {
}
public details = (): Errors.Details => ({
error: "Unprivileged"
error: "Unprivileged",
});
}
@@ -81,7 +82,7 @@ export class IncorrectPlayerRoleError extends ActionExecutionError {
public details = (): IncorrectPlayerRoleDetails => ({
error: "IncorrectPlayerRole",
role: this.role,
expected: this.expected
expected: this.expected,
});
}
@@ -109,7 +110,7 @@ export class IncorrectUserRoleError extends ActionExecutionError {
public details = (): IncorrectUserRoleDetails => ({
error: "IncorrectUserRole",
role: this.role,
expected: this.expected
expected: this.expected,
});
}
@@ -141,7 +142,7 @@ export class IncorrectRoundStageError extends ActionExecutionError {
public details = (): IncorrectRoundStageDetails => ({
error: "IncorrectRoundStage",
stage: this.stage,
expected: this.expected
expected: this.expected,
});
}
@@ -170,6 +171,48 @@ export class ConfigEditConflictError extends ActionExecutionError {
public details = (): ConfigEditConflictDetails => ({
error: "ConfigEditConflict",
version: this.version,
expected: this.expected
expected: this.expected,
});
}
interface SourceErrorDetails extends Errors.Details {
source: Source.External;
}
// Happens if the user asks for a deck that doesn't exist.
export class SourceNotFoundError extends ActionExecutionError {
public readonly source: Source.External;
public constructor(source: Source.External) {
super(
`The given deck (${source}) was not found at the source.`,
(undefined as unknown) as Action
);
this.source = source;
Error.captureStackTrace(this, SourceNotFoundError);
}
public details = (): SourceErrorDetails => ({
error: "SourceServiceError",
source: this.source,
});
}
// Happens if the deck service is down.
export class SourceServiceError extends ActionExecutionError {
public readonly source: Source.External;
public constructor(source: Source.External) {
super(
`The given deck source (${source.source}) was not available.`,
(undefined as unknown) as Action
);
this.source = source;
Error.captureStackTrace(this, SourceServiceError);
}
public details = (): SourceErrorDetails => ({
error: "SourceServiceError",
source: this.source,
});
}
+14 -2
View File
@@ -50,18 +50,30 @@ export interface BaseCard {
source: Source;
}
export type Style = "Em" | "Strong";
/** 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";
style?: Style;
}
export const isSlot = (part: Part): part is Slot => typeof part !== "string";
export interface Styled {
text: string;
style?: Style;
}
export const isSlot = (part: Part): part is Slot =>
typeof part !== "string" && !part.hasOwnProperty("text");
export const isStyled = (part: Part): part is Styled =>
typeof part !== "string" && part.hasOwnProperty("text");
/** Either text or a slot.*/
export type Part = string | Slot;
export type Part = string | Styled | Slot;
/**
* Create a new user id.
+28 -18
View File
@@ -2,6 +2,7 @@ import * as Cache from "../../cache";
import * as Decks from "./decks";
import { Cardcast } from "./sources/cardcast";
import { Custom } from "./sources/custom";
import { BuiltIn } from "./sources/builtIn";
/**
* A source for a card or deck.
@@ -11,7 +12,7 @@ export type Source = External | Custom;
/**
* An external source for a card or deck.
*/
export type External = Cardcast;
export type External = BuiltIn | Cardcast;
/**
* More information that can be looked up given a source.
@@ -54,14 +55,26 @@ export interface AtLeastTemplates {
summary?: Summary;
}
/**
* A resolver that only allows access to properties that don't require store
* access.
*/
export interface LimitedResolver<S extends External> {
id: () => string;
deckId: () => string;
loadingDetails: () => Details;
equals: (source: External) => boolean;
}
/**
* Resolve information about the given source.
*/
export abstract class Resolver implements LimitedResolver {
export abstract class Resolver<S extends External>
implements LimitedResolver<S> {
/**
* The source in question.
*/
public abstract source: Source;
public abstract source: S;
/**
* A unique id for the source as a whole.
@@ -133,31 +146,20 @@ export abstract class Resolver implements LimitedResolver {
}>;
}
/**
* 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;
export class CachedResolver<S extends External> extends Resolver<S> {
private readonly resolver: Resolver<S>;
private readonly cache: Cache.Cache;
public constructor(cache: Cache.Cache, resolver: Resolver) {
public constructor(cache: Cache.Cache, resolver: Resolver<S>) {
super();
this.cache = cache;
this.resolver = resolver;
}
public get source(): Source {
public get source(): S {
return this.resolver.source;
}
@@ -225,3 +227,11 @@ export class CachedResolver extends Resolver {
}
}
}
/**
* Get resolvers for the given source type.
*/
export interface MetaResolver<S extends External> {
limitedResolver(source: S): LimitedResolver<S>;
resolver(source: S): Resolver<S>;
}
+113 -44
View File
@@ -3,55 +3,124 @@ import * as Util from "../../util";
import * as Source from "./source";
import * as Cardcast from "./sources/cardcast";
import * as Player from "./sources/custom";
import * as Config from "../../config";
import * as BuiltIn from "./sources/builtIn";
import { SourceNotFoundError } from "../../errors/action-execution-error";
function uncachedResolver(source: Source.External): Source.Resolver {
switch (source.source) {
case "Cardcast":
return new Cardcast.Resolver(source);
default:
Util.assertNever(source.source);
async function loadIfEnabled<Config, MetaResolver>(
config: Config | undefined,
load: (value: Config) => Promise<MetaResolver>
): Promise<MetaResolver | undefined> {
if (config === undefined) {
return undefined;
} else {
return await load(config);
}
}
export class SourceNotFoundError extends Error {
public constructor() {
super("The given deck was not found at the source.");
}
}
export class SourceServiceError extends Error {
public constructor() {
super("The given source was not available.");
}
export interface ClientInfo {
builtIn?: BuiltIn.ClientInfo;
cardcast?: boolean;
}
/**
* Get the limited resolver for the given source.
*/
export const limitedResolver = (
source: Source.External
): Source.LimitedResolver => uncachedResolver(source);
export class Sources {
public readonly builtIn?: BuiltIn.MetaResolver;
public readonly cardcast?: Cardcast.MetaResolver;
/**
* Get the resolver for the given source.
*/
export const resolver = (
cache: Cache,
source: Source.External
): Source.Resolver =>
new Source.CachedResolver(cache, uncachedResolver(source));
/**
* Get the details for the given source.
*/
export const details = async (
cache: Cache,
source: Source.Source
): Promise<Source.Details> => {
switch (source.source) {
case "Custom":
return Player.details(source);
default:
return await resolver(cache, source).details();
public constructor(
builtIn?: BuiltIn.MetaResolver,
cardcast?: Cardcast.MetaResolver
) {
if (builtIn === undefined && cardcast === undefined) {
throw new Error("At least one source must be enabled.");
}
this.builtIn = builtIn;
this.cardcast = cardcast;
}
};
public clientInfo(): ClientInfo {
return {
...(this.builtIn !== undefined
? {
builtIn: this.builtIn.clientInfo(),
}
: {}),
...(this.cardcast !== undefined ? { cardcast: true } : {}),
};
}
private metaResolverIfConfigured(
source: Source.External
): Source.MetaResolver<Source.External> | undefined {
switch (source.source) {
case "BuiltIn":
return this.builtIn;
case "Cardcast":
return this.cardcast;
default:
Util.assertNever(source);
}
}
private metaResolver(
source: Source.External
): Source.MetaResolver<Source.External> {
const metaResolver = this.metaResolverIfConfigured(source);
if (metaResolver === undefined) {
throw new SourceNotFoundError(source);
} else {
return metaResolver;
}
}
/**
* Get the limited resolver for the given source.
*/
public limitedResolver(
source: Source.External
): Source.Resolver<Source.External> {
return this.metaResolver(source).resolver(source);
}
/**
* Get the resolver for the given source.
*/
public resolver(
cache: Cache,
source: Source.External
): Source.Resolver<Source.External> {
return new Source.CachedResolver(
cache,
this.metaResolver(source).resolver(source)
);
}
/**
* Get the details for the given source.
*/
details = async (
cache: Cache,
source: Source.Source
): Promise<Source.Details> => {
switch (source.source) {
case "Custom":
return Player.details(source);
default:
return await this.resolver(cache, source).details();
}
};
public static async from(config: Config.Sources): Promise<Sources> {
const [builtInMeta, cardcastMeta] = await Promise.all<
BuiltIn.MetaResolver | undefined,
Cardcast.MetaResolver | undefined
>([
loadIfEnabled(config.builtIn, BuiltIn.load),
loadIfEnabled(config.cardcast, Cardcast.load),
]);
return new Sources(builtInMeta, cardcastMeta);
}
}
@@ -0,0 +1,177 @@
import * as Source from "../source";
import * as Decks from "../decks";
import JSON5 from "json5";
import { promises as fs } from "fs";
import * as Config from "../../../config";
import * as path from "path";
import { Part } from "../card";
import * as Card from "../card";
import {
SourceNotFoundError,
SourceServiceError,
} from "../../../errors/action-execution-error";
const extension = ".deck.json5";
interface BuiltInDeck {
name: string;
calls: Part[][][];
responses: string[];
}
/**
* A source for built-in decks..
*/
export interface BuiltIn {
source: "BuiltIn";
id: string;
}
export interface ClientInfo {
decks: { name: string; id: string }[];
}
export class Resolver extends Source.Resolver<BuiltIn> {
public readonly source: BuiltIn;
private config: Config.BuiltIn;
/**
* Can be undefined because we want to error out later if the deck doesn't exist for nicer errors.
*/
private readonly storedSummary?: Source.Summary;
public constructor(
config: Config.BuiltIn,
source: BuiltIn,
summary?: Source.Summary
) {
super();
this.config = config;
this.source = source;
this.storedSummary = summary;
}
public id(): string {
return "BuiltIn";
}
public deckId(): string {
return this.source.id;
}
public loadingDetails(): Source.Details {
if (this.storedSummary !== undefined) {
return this.storedSummary.details;
}
return { name: "Deck Not Found" };
}
public equals(source: Source.External): boolean {
return source.source === "BuiltIn" && this.source.id == source.id;
}
public async getTag(): Promise<string | undefined> {
return undefined;
}
public async atLeastSummary(): Promise<Source.AtLeastSummary> {
if (this.storedSummary === undefined) {
throw new SourceNotFoundError(this.source);
}
return {
summary: this.storedSummary,
};
}
public async atLeastTemplates(): Promise<Source.AtLeastTemplates> {
return this.summaryAndTemplates();
}
public summaryAndTemplates = async (): Promise<{
summary: Source.Summary;
templates: Decks.Templates;
}> => {
if (this.storedSummary === undefined) {
throw new SourceNotFoundError(this.source);
}
try {
const rawDeck = JSON5.parse(
(
await fs.readFile(
path.join(this.config.basePath, this.source.id + extension)
)
).toString()
) as BuiltInDeck;
return {
summary: this.storedSummary,
templates: {
calls: new Set(rawDeck.calls.map(this.call)),
responses: new Set(rawDeck.responses.map(this.response)),
},
};
} catch (error) {
throw new SourceServiceError(this.source);
}
};
private call = (call: Card.Part[][]): Card.Call => ({
id: Card.id(),
parts: call,
source: this.source,
});
private response = (response: string): Card.Response => ({
id: Card.id(),
text: response,
source: this.source,
});
}
export class MetaResolver implements Source.MetaResolver<BuiltIn> {
private readonly config: Config.BuiltIn;
private readonly summaries: Map<string, Source.Summary>;
public constructor(
config: Config.BuiltIn,
summaries: Map<string, Source.Summary>
) {
this.config = config;
this.summaries = summaries;
}
limitedResolver(source: BuiltIn): Source.LimitedResolver<BuiltIn> {
return this.resolver(source);
}
resolver(source: BuiltIn): Resolver {
const summary = this.summaries.get(source.id);
return new Resolver(this.config, source, summary);
}
public clientInfo(): ClientInfo {
return {
decks: this.config.decks.map((id) => ({
name: (this.summaries.get(id) as Source.Summary).details.name,
id,
})),
};
}
}
export async function load(config: Config.BuiltIn): Promise<MetaResolver> {
const summaries = new Map<string, Source.Summary>();
for (const id of config.decks) {
const rawDeck = JSON5.parse(
(await fs.readFile(path.join(config.basePath, id + extension))).toString()
) as BuiltInDeck;
summaries.set(id, {
details: {
name: rawDeck.name,
},
calls: rawDeck.calls.length,
responses: rawDeck.responses.length,
});
}
return new MetaResolver(config, summaries);
}
+68 -45
View File
@@ -1,11 +1,15 @@
import http, { AxiosRequestConfig } from "axios";
import http, { AxiosInstance, AxiosRequestConfig } from "axios";
import genericPool from "generic-pool";
import HttpStatus from "http-status-codes";
import * as Card from "../card";
import { Slot } from "../card";
import * as Decks from "../decks";
import * as Source from "../source";
import { SourceNotFoundError, SourceServiceError } from "../sources";
import * as Config from "../../../config";
import {
SourceNotFoundError,
SourceServiceError,
} from "../../../errors/action-execution-error";
interface CCSummary {
name: string;
@@ -38,27 +42,6 @@ interface CCCard {
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 _ => {
// Do nothing.
}
},
{ max: 2 }
);
const summaryUrl = (playCode: PlayCode): string => `decks/${playCode}`;
const deckUrl = (playCode: PlayCode): string => `${summaryUrl(playCode)}/cards`;
const humanViewUrl = (playCode: PlayCode): string =>
@@ -88,7 +71,7 @@ const nextWordShouldBeCapitalized = (previously: string): boolean =>
*/
// TODO: We probably want to offer some control over these heuristics.
function* parts(call: CCCard): Iterable<Card.Part> {
const upper: Slot = call.text.every(text => text === text.toUpperCase())
const upper: Slot = call.text.every((text) => text === text.toUpperCase())
? { transform: "UpperCase" }
: {};
let first = true;
@@ -114,21 +97,26 @@ function* parts(call: CCCard): Iterable<Card.Part> {
const call = (source: Cardcast, call: CCCard): Card.Call => ({
id: Card.id(),
parts: [Array.from(parts(call))],
source: source
source: source,
});
const response = (source: Cardcast, response: CCCard): Card.Response => ({
id: Card.id(),
text: response.text[0],
source: source
source: source,
});
export class Resolver extends Source.Resolver {
export class Resolver extends Source.Resolver<Cardcast> {
public readonly source: Cardcast;
private readonly connectionPool: genericPool.Pool<AxiosInstance>;
public constructor(source: Cardcast) {
public constructor(
source: Cardcast,
connectionPool: genericPool.Pool<AxiosInstance>
) {
super();
this.source = source;
this.connectionPool = connectionPool;
}
public id(): string {
@@ -142,7 +130,7 @@ export class Resolver extends Source.Resolver {
public loadingDetails(): Source.Details {
return {
name: `Cardcast ${this.source.playCode}`,
url: humanViewUrl(this.source.playCode)
url: humanViewUrl(this.source.playCode),
};
}
@@ -158,49 +146,47 @@ export class Resolver extends Source.Resolver {
}
public async atLeastSummary(): Promise<Source.AtLeastSummary> {
const summary = await Resolver.get<CCSummary>(
summaryUrl(this.source.playCode)
);
const summary = await this.get<CCSummary>(summaryUrl(this.source.playCode));
return {
summary: {
details: {
name: summary.name,
url: humanViewUrl(this.source.playCode)
url: humanViewUrl(this.source.playCode),
},
calls: Number.parseInt(summary.call_count, 10),
responses: Number.parseInt(summary.response_count, 10),
tag: summary.updated_at
}
tag: summary.updated_at,
},
};
}
public async atLeastTemplates(): Promise<Source.AtLeastTemplates> {
const deck = await Resolver.get<CCDeck>(deckUrl(this.source.playCode));
const deck = await this.get<CCDeck>(deckUrl(this.source.playCode));
return {
templates: {
calls: new Set(deck.calls.map(c => call(this.source, c))),
responses: new Set(deck.responses.map(r => response(this.source, r)))
}
calls: new Set(deck.calls.map((c) => call(this.source, c))),
responses: new Set(deck.responses.map((r) => response(this.source, r))),
},
};
}
private static async get<T>(url: string): Promise<T> {
const connection = await connectionPool.acquire();
private async get<T>(url: string): Promise<T> {
const connection = await this.connectionPool.acquire();
try {
return (await connection.get(url)).data;
} catch (error) {
if (error.response) {
const response = error.response;
if (response.status === HttpStatus.NOT_FOUND) {
throw new SourceNotFoundError();
throw new SourceNotFoundError(this.source);
} else {
throw new SourceServiceError();
throw new SourceServiceError(this.source);
}
} else {
throw error;
}
} finally {
await connectionPool.release(connection);
await this.connectionPool.release(connection);
}
}
@@ -209,6 +195,43 @@ export class Resolver extends Source.Resolver {
templates: Decks.Templates;
}> => ({
summary: await this.summary(),
templates: await this.templates()
templates: await this.templates(),
});
}
export class MetaResolver implements Source.MetaResolver<Cardcast> {
/**
* We pool requests to cardcast to stop us hitting them too hard (on top of caching).
*/
private readonly connectionPool: genericPool.Pool<AxiosInstance>;
public constructor(config: Config.Cardcast) {
const httpConfig: AxiosRequestConfig = {
method: "GET",
baseURL: "https://api.cardcastgame.com/v1/",
timeout: config.timeout,
responseType: "json",
};
this.connectionPool = genericPool.createPool(
{
create: async () => http.create(httpConfig),
destroy: async (_) => {
// Do nothing.
},
},
{ max: config.simultaneousConnections }
);
}
limitedResolver(source: Cardcast): Resolver {
return this.resolver(source);
}
resolver(source: Cardcast): Resolver {
return new Resolver(source, this.connectionPool);
}
}
export const load = async (config: Config.Cardcast): Promise<MetaResolver> =>
new MetaResolver(config);
+24 -19
View File
@@ -3,12 +3,11 @@ 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 { promises as fs } from "fs";
import helmet from "helmet";
import HttpStatus from "http-status-codes";
import JSON5 from "json5";
import sourceMapSupport from "source-map-support";
import { promisify } from "util";
import wu from "wu";
import * as CheckAlive from "./action/initial/check-alive";
import * as CreateLobby from "./action/initial/create-lobby";
@@ -31,11 +30,11 @@ import * as Token from "./user/token";
sourceMapSupport.install();
process.on("uncaughtException", function(error) {
process.on("uncaughtException", function (error) {
Logging.logException("Uncaught exception: ", error);
});
process.on("unhandledRejection", function(reason, promise) {
process.on("unhandledRejection", function (reason, promise) {
if (reason instanceof Error) {
Logging.logException(`Unhandled rejection for ${promise}.`, reason);
} else {
@@ -51,7 +50,7 @@ function getConfigFilePath(): string {
async function main(): Promise<void> {
const config = ServerConfig.parse(
JSON5.parse(
(await promisify(fs.readFile)(getConfigFilePath())).toString()
(await fs.readFile(getConfigFilePath())).toString()
) as ServerConfig.Unparsed
);
@@ -75,7 +74,7 @@ async function main(): Promise<void> {
app.use(
expressWinston.logger({
winstonInstance: Logging.logger
winstonInstance: Logging.logger,
})
);
@@ -123,12 +122,12 @@ async function main(): Promise<void> {
const registration = RegisterUser.validate(req.body);
const newUser = User.create(registration);
const id = await Change.applyAndReturn(state, gameCode, lobby => {
const id = await Change.applyAndReturn(state, gameCode, (lobby) => {
if (lobby.config.password !== registration.password) {
throw new InvalidLobbyPasswordError();
}
if (
wu(Object.values(lobby.users)).find(u => u.name === registration.name)
wu(Object.values(lobby.users)).find((u) => u.name === registration.name)
) {
throw new UsernameAlreadyInUseError(registration.name);
}
@@ -155,22 +154,22 @@ async function main(): Promise<void> {
lobby,
events: [
Event.targetAll(PresenceChanged.joined(id, newUser)),
...(unpause.events !== undefined ? unpause.events : [])
...(unpause.events !== undefined ? unpause.events : []),
],
timeouts: [
{
timeout: UserDisconnect.of(id),
after: config.timeouts.disconnectionGracePeriod
after: config.timeouts.disconnectionGracePeriod,
},
...(unpause.timeouts !== undefined ? unpause.timeouts : [])
]
...(unpause.timeouts !== undefined ? unpause.timeouts : []),
],
},
returnValue: id
returnValue: id,
};
});
const claims: Token.Claims = {
gc: gameCode,
uid: id
uid: id,
};
res.json(Token.create(claims, await state.store.id(), config.secret));
});
@@ -180,6 +179,10 @@ async function main(): Promise<void> {
state.socketManager.add(state, gameCode, socket);
});
app.get("/api/sources", async (req, res) => {
res.json(state.sources.clientInfo());
});
app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) {
next(error);
@@ -195,7 +198,7 @@ async function main(): Promise<void> {
app.use(
expressWinston.errorLogger({
winstonInstance: Logging.logger,
msg: "{{err.message}}"
msg: "{{err.message}}",
})
);
@@ -237,18 +240,20 @@ async function main(): Promise<void> {
state.tasks
.loadFromStore(state)
.catch(error => Logging.logException("Error running store tasks:", error));
.catch((error) =>
Logging.logException("Error running store tasks:", error)
);
app.listen(config.listenOn, async () => {
Logging.logger.info(`Listening on ${config.listenOn}.`);
if (config.touchOnStart !== null) {
const f = await promisify(fs.open)(config.touchOnStart, "w");
await promisify(fs.close)(f);
const f = await fs.open(config.touchOnStart, "w");
await f.close();
}
});
}
main().catch(error => {
main().catch((error) => {
Logging.logException("Application exception:", error);
process.exit(1);
});
+5 -1
View File
@@ -5,9 +5,11 @@ import { SocketManager } from "./socket-manager";
import { Store } from "./store";
import * as Stores from "./store/stores";
import * as Tasks from "./task/tasks";
import { Sources } from "./games/cards/sources";
export interface ServerState {
config: Config.Parsed;
sources: Sources;
store: Store;
cache: Cache;
socketManager: SocketManager;
@@ -15,16 +17,18 @@ export interface ServerState {
}
export async function create(config: Config.Parsed): Promise<ServerState> {
const sources = await Sources.from(config.sources);
const store = await Stores.from(config.storage);
const cache = await caches.from(config.cache);
const socketManager = new SocketManager();
const tasks = new Tasks.Queue(config.tasks.rateLimit);
return {
sources,
config,
store,
cache,
socketManager,
tasks
tasks,
};
}
+39 -22
View File
@@ -2,17 +2,16 @@ import Rfc6902 from "rfc6902";
import * as Event from "../event";
import * as Configured from "../events/lobby-event/configured";
import * as Source from "../games/cards/source";
import * as Sources from "../games/cards/sources";
import {
SourceNotFoundError,
SourceServiceError
} from "../games/cards/sources";
import { Lobby } from "../lobby";
import { Change } from "../lobby/change";
import * as Config from "../lobby/config";
import { GameCode } from "../lobby/game-code";
import { ServerState } from "../server-state";
import * as Task from "../task";
import {
SourceNotFoundError,
SourceServiceError,
} from "../errors/action-execution-error";
export class LoadDeckSummary extends Task.TaskBase<Source.Summary> {
private readonly source: Source.External;
@@ -23,7 +22,8 @@ export class LoadDeckSummary extends Task.TaskBase<Source.Summary> {
}
protected async begin(server: ServerState): Promise<Source.Summary> {
const loaded = await Sources.resolver(server.cache, this.source)
const loaded = await server.sources
.resolver(server.cache, this.source)
// We are intentionally ensuring the templates get cached here in advance,
// but don't actually need to do anything with them at this point.
.summaryAndTemplates();
@@ -32,35 +32,48 @@ export class LoadDeckSummary extends Task.TaskBase<Source.Summary> {
private resolveInternal(
lobby: Lobby,
modify: (source: Config.ConfiguredSource) => void
modify: (source: Config.ConfiguredSource) => void,
server: ServerState
): Change {
const lobbyConfig = lobby.config;
const oldConfig = JSON.parse(JSON.stringify(Config.censor(lobby.config)));
const decks = lobbyConfig.decks;
const resolver = Sources.limitedResolver(this.source);
const target = decks.find(deck => resolver.equals(deck.source));
const resolver = server.sources.limitedResolver(this.source);
const target = decks.find((deck) => resolver.equals(deck.source));
if (target !== undefined) {
modify(target);
lobbyConfig.version += 1;
const patch = Rfc6902.createPatch(oldConfig, Config.censor(lobbyConfig));
return {
lobby,
events: [Event.targetAll(Configured.of(patch))]
events: [Event.targetAll(Configured.of(patch))],
};
} else {
return {};
}
}
protected resolve(lobby: Lobby, work: Source.Summary): Change {
return this.resolveInternal(lobby, summarised => {
if (!Config.isFailed(summarised)) {
summarised.summary = { ...work, tag: undefined };
}
});
protected resolve(
lobby: Lobby,
work: Source.Summary,
server: ServerState
): Change {
return this.resolveInternal(
lobby,
(summarised) => {
if (!Config.isFailed(summarised)) {
summarised.summary = { ...work, tag: undefined };
}
},
server
);
}
protected resolveError(lobby: Lobby, error: Error): Change {
protected resolveError(
lobby: Lobby,
error: Error,
server: ServerState
): Change {
let reason: Config.FailReason;
if (error instanceof SourceNotFoundError) {
reason = "NotFound";
@@ -69,11 +82,15 @@ export class LoadDeckSummary extends Task.TaskBase<Source.Summary> {
} else {
throw error;
}
return this.resolveInternal(lobby, failed => {
if (!failed.hasOwnProperty("summary")) {
(failed as Config.FailedSource).failure = reason;
}
});
return this.resolveInternal(
lobby,
(failed) => {
if (!failed.hasOwnProperty("summary")) {
(failed as Config.FailedSource).failure = reason;
}
},
server
);
}
public static *discover(
+3 -3
View File
@@ -19,8 +19,8 @@ export class StartGame extends Task.TaskBase<Decks.Templates[]> {
protected async begin(server: ServerState): Promise<Decks.Templates[]> {
return Promise.all(
wu(this.decks).map(deck =>
Sources.resolver(server.cache, deck).templates()
wu(this.decks).map((deck) =>
server.sources.resolver(server.cache, deck).templates()
)
);
}
@@ -42,7 +42,7 @@ export class StartGame extends Task.TaskBase<Decks.Templates[]> {
return {
lobby,
events: atStartOfRound.events,
timeouts: atStartOfRound.timeouts
timeouts: atStartOfRound.timeouts,
};
}