diff --git a/client/elm.json b/client/elm.json index 317dc347..87736678 100644 --- a/client/elm.json +++ b/client/elm.json @@ -13,6 +13,7 @@ "elm/http": "2.0.0", "elm/json": "1.1.3", "elm/random": "1.0.0", + "elm/regex": "1.0.0", "elm/svg": "1.0.1", "elm/time": "1.0.0", "elm/url": "1.0.0", @@ -29,7 +30,6 @@ "indirect": { "elm/bytes": "1.0.8", "elm/file": "1.0.5", - "elm/regex": "1.0.0", "elm/virtual-dom": "1.0.2", "elm-community/list-extra": "8.2.3", "owanturist/elm-union-find": "1.0.0" diff --git a/client/package-lock.json b/client/package-lock.json index 30315f44..e7e31310 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -4868,9 +4868,9 @@ } }, "html-webpack-plugin": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.2.1.tgz", - "integrity": "sha512-zTTPxKJ8bgRe4RVDzT1MZW8ysW5wwDfJmD3AN+7mw2MKMWZJibZzBgHaDqnL6FJg1kvk38sQPMJNmI8Q1Ntr9A==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.3.0.tgz", + "integrity": "sha512-C0fzKN8yQoVLTelcJxZfJCE+aAvQiY2VUf3UuKrR4a9k5UMWYOtpDLsaXwATbcVCnI05hUS7L9ULQHWLZhyi3w==", "dev": true, "requires": { "@types/html-minifier-terser": "^5.0.0", diff --git a/client/package.json b/client/package.json index e1810bc9..f6f9bc6d 100644 --- a/client/package.json +++ b/client/package.json @@ -43,7 +43,7 @@ "extract-loader": "^5.0.1", "file-loader": "^6.0.0", "html-loader": "^1.1.0", - "html-webpack-plugin": "^4.2.1", + "html-webpack-plugin": "^4.3.0", "node-sass": "^4.14.0", "postcss-import": "^12.0.1", "postcss-loader": "^3.0.0", diff --git a/client/src/elm/MassiveDecks.elm b/client/src/elm/MassiveDecks.elm index 201d2485..ede797c6 100644 --- a/client/src/elm/MassiveDecks.elm +++ b/client/src/elm/MassiveDecks.elm @@ -4,6 +4,7 @@ import Browser import Browser.Navigation as Navigation import Html exposing (Html) import Html.Attributes as HtmlA +import Http import Json.Decode as Json import MassiveDecks.Cast.Client as Cast import MassiveDecks.Cast.Model as Cast @@ -28,6 +29,8 @@ import MassiveDecks.Pages.Route as Route exposing (Route) import MassiveDecks.Pages.Start as Start import MassiveDecks.Pages.Start.Route as Start import MassiveDecks.Pages.Unknown as Unknown +import MassiveDecks.Requests.Api as Api +import MassiveDecks.Requests.Request as Request import MassiveDecks.ServerConnection as ServerConnection import MassiveDecks.Settings as Settings import MassiveDecks.Settings.Messages as Settings @@ -85,6 +88,7 @@ init flags url key = , speech = speech , notifications = Notifications.init , remoteMode = remoteMode + , sources = { builtIn = Nothing, cardcast = False } } ( page, pageCmd ) = @@ -93,9 +97,12 @@ init flags url key = else ( Pages.Loading, Cmd.none ) + + sourceCmd = + Request.map (Error.Add >> ErrorMsg) never UpdateSources |> Api.sourceInfo |> Http.request in ( { page = page, shared = shared, errorOverlay = Overlay.init } - , Cmd.batch [ pageCmd, settingsCmd, speechCmd ] + , Cmd.batch [ sourceCmd, pageCmd, settingsCmd, speechCmd ] ) @@ -264,6 +271,13 @@ update msg model = in ( { model | shared = { oldShared | notifications = notifications } }, notificationsCmd ) + UpdateSources info -> + let + oldShared = + model.shared + in + ( { model | shared = { oldShared | sources = info } }, Cmd.none ) + Refresh -> ( model, Navigation.reload ) @@ -284,7 +298,7 @@ update msg model = Settings.update model.shared (Settings.ChangeLang (Just language)) ( lobby, lobbyCmd ) = - Lobby.initWithAuth { gameCode = auth.claims.gc, section = Just Lobby.Spectate } auth + Lobby.initWithAuth shared { gameCode = auth.claims.gc, section = Just Lobby.Spectate } auth in ( { model | page = Pages.Lobby lobby diff --git a/client/src/elm/MassiveDecks/Card/Parts.elm b/client/src/elm/MassiveDecks/Card/Parts.elm index 2129d9ed..1689c615 100644 --- a/client/src/elm/MassiveDecks/Card/Parts.elm +++ b/client/src/elm/MassiveDecks/Card/Parts.elm @@ -1,6 +1,7 @@ module MassiveDecks.Card.Parts exposing ( Part(..) , Parts + , Style(..) , Transform(..) , fromList , map @@ -19,16 +20,24 @@ import MassiveDecks.Util.String as String {-| A transform to apply to the value in a slot. -} type Transform - = UpperCase + = NoTransform + | UpperCase | Capitalize - | Stay + + +{-| A style to be applied to some text. +-} +type Style + = NoStyle + | Em + | Strong {-| A part of a call's text. This is either just text or a position for a call to be inserted in-game. -} type Part - = Text String - | Slot Transform + = Text String Style + | Slot Transform Style {-| Represents a line as a part of a part. Between each one the text will be forced to line break. @@ -48,10 +57,10 @@ type Parts isSlot : Part -> Bool isSlot part = case part of - Text _ -> + Text _ _ -> False - Slot _ -> + Slot _ _ -> True @@ -136,18 +145,18 @@ viewLinesString blankPhrase = viewLines (\s -> \p -> viewPartsString blankPhrase s p |> String.join "") -viewParts : (Bool -> String -> List a) -> a -> List String -> List Part -> List a +viewParts : (Bool -> String -> Style -> List a) -> a -> List String -> List Part -> List a viewParts viewText emptySlot play parts = case parts of firstPart :: restParts -> case firstPart of - Text string -> - viewText False string ++ viewParts viewText emptySlot play restParts + Text string style -> + viewText False string style ++ viewParts viewText emptySlot play restParts - Slot transform -> + Slot transform style -> case play of firstPlay :: restPlay -> - viewText True (applyTransform transform firstPlay) ++ viewParts viewText emptySlot restPlay restParts + viewText True (applyTransform transform firstPlay) style ++ viewParts viewText emptySlot restPlay restParts [] -> emptySlot :: viewParts viewText emptySlot [] restParts @@ -163,27 +172,38 @@ viewPartsHtml = viewPartsString : String -> List String -> List Part -> List String viewPartsString blankPhrase = - viewParts (\_ -> \s -> [ s ]) blankPhrase + viewParts (\_ -> \s -> \_ -> [ s ]) blankPhrase applyTransform : Transform -> String -> String applyTransform transform value = case transform of + NoTransform -> + value + UpperCase -> String.toUpper value Capitalize -> String.capitalise value - Stay -> - value - -viewTextHtml : Bool -> String -> List (Html msg) -viewTextHtml slot string = +viewTextHtml : Bool -> String -> Style -> List (Html msg) +viewTextHtml slot string style = let + element = + case style of + NoStyle -> + Html.span + + Em -> + Html.em + + Strong -> + Html.strong + words = - string |> splitWords |> List.map (\word -> Html.span [] [ Html.text word ]) + string |> splitWords |> List.map (\word -> element [] [ Html.text word ]) in if slot then [ Html.span [ HtmlA.class "slot" ] words ] diff --git a/client/src/elm/MassiveDecks/Card/Response.elm b/client/src/elm/MassiveDecks/Card/Response.elm index a3ff94e4..f3aff42f 100644 --- a/client/src/elm/MassiveDecks/Card/Response.elm +++ b/client/src/elm/MassiveDecks/Card/Response.elm @@ -16,6 +16,7 @@ import MassiveDecks.Model exposing (Shared) import MassiveDecks.Pages.Lobby.Configure.Decks as Decks import MassiveDecks.Pages.Lobby.Configure.Model exposing (Config) import MassiveDecks.Util.String as String +import Regex exposing (Regex) {-| Render the response to HTML. @@ -71,9 +72,23 @@ viewCustom shared config side update canonicalize attributes response fill = {- Private -} +punctuation : Regex +punctuation = + -- TODO: This should probably get localized. + Regex.fromString "[.?!]$" |> Maybe.withDefault Regex.never + + viewBody : Response -> ViewBody msg viewBody response = - ViewBody (\() -> [ Html.p [] [ Html.span [] [ response.body |> String.capitalise |> Html.text ] ] ]) + let + end = + if response.body |> Regex.contains punctuation then + [] + + else + [ Html.text "." ] + in + ViewBody (\() -> [ Html.p [] [ Html.span [] ((response.body |> String.capitalise |> Html.text) :: end) ] ]) viewCustomBody : String -> (String -> msg) -> (String -> msg) -> String -> Maybe String -> ViewBody msg diff --git a/client/src/elm/MassiveDecks/Card/Source.elm b/client/src/elm/MassiveDecks/Card/Source.elm index eac396ca..605cbb97 100644 --- a/client/src/elm/MassiveDecks/Card/Source.elm +++ b/client/src/elm/MassiveDecks/Card/Source.elm @@ -17,6 +17,7 @@ module MassiveDecks.Card.Source exposing import Html exposing (Html) import Html.Attributes as HtmlA import Html.Events as HtmlE +import MassiveDecks.Card.Source.BuiltIn as BuiltIn import MassiveDecks.Card.Source.Cardcast as Cardcast import MassiveDecks.Card.Source.Custom as Player import MassiveDecks.Card.Source.Fake as Fake @@ -27,15 +28,16 @@ import MassiveDecks.Model exposing (..) import MassiveDecks.Pages.Lobby.Configure.Decks.Model exposing (DeckOrError) import MassiveDecks.Strings as Strings exposing (MdString) import MassiveDecks.Strings.Languages as Lang +import MassiveDecks.Util.Maybe as Maybe import Weightless as Wl import Weightless.Attributes as WlA {-| The default source for an editor. -} -default : External +default : Shared -> External default = - Cardcast.generalMethods.empty () + BuiltIn.generalMethods.empty {-| Check if two sources are equal. @@ -57,23 +59,33 @@ externalAndEquals a b = False -{-| Get an empty source of the given type. +{-| Get an general methods of the given type. -} -empty : String -> Maybe External -empty n = +generalMethods : String -> Maybe (ExternalGeneralMethods msg) +generalMethods n = case n of + "BuiltIn" -> + BuiltIn.generalMethods |> Just + "Cardcast" -> - () |> Cardcast.generalMethods.empty |> Just + Cardcast.generalMethods |> Just _ -> Nothing +{-| Get an empty source of the given type. +-} +empty : Shared -> String -> Maybe External +empty shared n = + generalMethods n |> Maybe.map (\m -> m.empty shared) + + {-| An empty source of the same general type as the given one. -} -emptyMatching : External -> External -emptyMatching source = - () |> (externalMethods source |> .empty) +emptyMatching : Shared -> External -> External +emptyMatching shared source = + shared |> (externalMethods source |> .empty) {-| The name of a source. @@ -99,9 +111,9 @@ defaultDetails shared source = {-| A tooltip for a source. -} -tooltip : Source -> Maybe ( String, Html msg ) -tooltip source = - case () |> (methods source |> .tooltip) of +tooltip : Shared -> Source -> Maybe ( String, Html msg ) +tooltip shared source = + case shared |> (methods source |> .tooltip) of Just ( id, rendered ) -> Just ( id @@ -134,15 +146,23 @@ logo source = -} generalEditor : Shared -> List DeckOrError -> External -> (External -> msg) -> List (Html msg) generalEditor shared existing currentValue update = + let + enabledSources = + [ shared.sources.builtIn |> Maybe.map (\_ -> BuiltIn.generalMethods) + , Cardcast.generalMethods |> Maybe.justIf shared.sources.cardcast + ] + + toOption source = + Html.option [ HtmlA.value (source.id ()) ] + [ () |> source.name |> Lang.html shared + ] + in [ Wl.select [ HtmlA.id "source-selector" , WlA.outlined - , HtmlE.onInput (empty >> Maybe.withDefault default >> update) - ] - [ Html.option [ HtmlA.value "Cardcast" ] - [ Strings.Cardcast |> Lang.html shared - ] + , HtmlE.onInput (empty shared >> Maybe.withDefault (default shared) >> update) ] + (enabledSources |> List.filterMap (Maybe.map toOption)) , editor shared existing currentValue update ] @@ -197,3 +217,6 @@ externalMethods external = case external of Cardcast playCode -> Cardcast.methods playCode + + BuiltIn id -> + BuiltIn.methods id diff --git a/client/src/elm/MassiveDecks/Card/Source/BuiltIn.elm b/client/src/elm/MassiveDecks/Card/Source/BuiltIn.elm new file mode 100644 index 00000000..6064a8a8 --- /dev/null +++ b/client/src/elm/MassiveDecks/Card/Source/BuiltIn.elm @@ -0,0 +1,132 @@ +module MassiveDecks.Card.Source.BuiltIn exposing + ( generalMethods + , methods + ) + +import Html as Html exposing (Html) +import Html.Attributes as HtmlA +import Html.Events as HtmlE +import MassiveDecks.Card.Source.BuiltIn.Model exposing (..) +import MassiveDecks.Card.Source.Methods as Source +import MassiveDecks.Card.Source.Model as Source exposing (Source) +import MassiveDecks.Components.Form.Message exposing (Message) +import MassiveDecks.Model exposing (..) +import MassiveDecks.Pages.Lobby.Configure.Decks.Model as Decks exposing (DeckOrError) +import MassiveDecks.Strings as Strings exposing (MdString) +import MassiveDecks.Util.Html as Html +import Weightless as Wl +import Weightless.Attributes as WlA + + +methods : Id -> Source.ExternalMethods msg +methods given = + { name = name + , logo = logo + , empty = empty + , id = id + , problems = problems given + , defaultDetails = details given + , tooltip = tooltip given + , editor = editor given + , equals = equals given + } + + +generalMethods : Source.ExternalGeneralMethods msg +generalMethods = + { name = name + , logo = logo + , empty = empty + , id = id + } + + + +{- Private -} + + +id : () -> String +id _ = + "BuiltIn" + + +name : () -> MdString +name () = + Strings.BuiltIn + + +empty : Shared -> Source.External +empty shared = + shared.sources.builtIn + |> Maybe.andThen (.decks >> List.head) + |> Maybe.map .id + |> Maybe.withDefault (Id "") + |> Source.BuiltIn + + +equals : Id -> Source.External -> Bool +equals (Id given) source = + case source of + Source.BuiltIn (Id other) -> + given == other + + _ -> + False + + +problems : Id -> () -> List (Message msg) +problems _ () = + [] + + +editor : Id -> Shared -> List DeckOrError -> (Source.External -> msg) -> Html msg +editor (Id selectedId) shared _ update = + case shared.sources.builtIn of + Just { decks } -> + let + deck deckInfo = + case deckInfo.id of + Id other -> + Html.option + [ HtmlA.selected (selectedId == other) + , HtmlA.value other + ] + [ Html.text deckInfo.name ] + in + Html.div [ HtmlA.class "primary" ] + [ Wl.select + [ HtmlA.id "built-in-selector" + , WlA.outlined + , Id >> Source.BuiltIn >> update |> HtmlE.onInput + ] + (decks |> List.map deck) + ] + + Nothing -> + Html.nothing + + +details : Id -> Shared -> Source.Details +details (Id given) shared = + let + isSame deckInfo = + case deckInfo.id of + Id other -> + given == other + in + { name = + shared.sources.builtIn + |> Maybe.andThen (.decks >> List.filter isSame >> List.head >> Maybe.map .name) + |> Maybe.withDefault "" + , url = Nothing + } + + +tooltip : Id -> Shared -> Maybe ( String, Html msg ) +tooltip (Id given) shared = + ( "builtin-" ++ given, Html.span [] [ details (Id given) shared |> .name |> Html.text ] ) |> Just + + +logo : () -> Maybe (Html msg) +logo () = + Nothing diff --git a/client/src/elm/MassiveDecks/Card/Source/BuiltIn/Model.elm b/client/src/elm/MassiveDecks/Card/Source/BuiltIn/Model.elm new file mode 100644 index 00000000..3a09861d --- /dev/null +++ b/client/src/elm/MassiveDecks/Card/Source/BuiltIn/Model.elm @@ -0,0 +1,17 @@ +module MassiveDecks.Card.Source.BuiltIn.Model exposing (Id(..)) + +{-| Models for the built-in source. +-} + + +{-| The id for a built-in deck. +-} +type Id + = Id String + + +{-| Create an id from a string. +-} +playCode : String -> Id +playCode string = + string |> Id diff --git a/client/src/elm/MassiveDecks/Card/Source/Cardcast.elm b/client/src/elm/MassiveDecks/Card/Source/Cardcast.elm index 8059aeca..8ca7ea9f 100644 --- a/client/src/elm/MassiveDecks/Card/Source/Cardcast.elm +++ b/client/src/elm/MassiveDecks/Card/Source/Cardcast.elm @@ -25,6 +25,7 @@ methods playCode = { name = name , logo = logo , empty = empty + , id = id , problems = problems playCode , defaultDetails = details playCode , tooltip = tooltip playCode @@ -38,6 +39,7 @@ generalMethods = { name = name , logo = logo , empty = empty + , id = id } @@ -45,13 +47,18 @@ generalMethods = {- Private -} +id : () -> String +id () = + "Cardcast" + + name : () -> MdString name () = Strings.Cardcast -empty : () -> Source.External -empty () = +empty : Shared -> Source.External +empty shared = "" |> playCode |> Source.Cardcast @@ -61,6 +68,9 @@ equals (PlayCode pc) source = Source.Cardcast (PlayCode other) -> pc == other + _ -> + False + problems : PlayCode -> () -> List (Message msg) problems (PlayCode pc) () = @@ -92,6 +102,9 @@ editor (PlayCode pc) shared existing update = in playCode |> Maybe.justIf (existing |> List.all notSameDeck) + _ -> + Nothing + recentDeck (PlayCode recent) = Html.option [ HtmlA.value recent ] [] in @@ -116,8 +129,8 @@ details (PlayCode pc) shared = } -tooltip : PlayCode -> () -> Maybe ( String, Html msg ) -tooltip (PlayCode pc) () = +tooltip : PlayCode -> Shared -> Maybe ( String, Html msg ) +tooltip (PlayCode pc) _ = ( "cardcast-" ++ pc, Html.span [] [ logoInternal, Html.text pc ] ) |> Just diff --git a/client/src/elm/MassiveDecks/Card/Source/Custom.elm b/client/src/elm/MassiveDecks/Card/Source/Custom.elm index e22db737..be20e04e 100644 --- a/client/src/elm/MassiveDecks/Card/Source/Custom.elm +++ b/client/src/elm/MassiveDecks/Card/Source/Custom.elm @@ -15,7 +15,7 @@ methods = { name = name shared , url = Nothing } - , tooltip = \() -> Nothing + , tooltip = \_ -> Nothing } diff --git a/client/src/elm/MassiveDecks/Card/Source/Fake.elm b/client/src/elm/MassiveDecks/Card/Source/Fake.elm index 2f5c0cf9..ec8b9b6c 100644 --- a/client/src/elm/MassiveDecks/Card/Source/Fake.elm +++ b/client/src/elm/MassiveDecks/Card/Source/Fake.elm @@ -13,5 +13,5 @@ methods = { name = "" , url = Nothing } - , tooltip = \() -> Nothing + , tooltip = \_ -> Nothing } diff --git a/client/src/elm/MassiveDecks/Card/Source/Methods.elm b/client/src/elm/MassiveDecks/Card/Source/Methods.elm index 781571d9..0e39a6f7 100644 --- a/client/src/elm/MassiveDecks/Card/Source/Methods.elm +++ b/client/src/elm/MassiveDecks/Card/Source/Methods.elm @@ -74,7 +74,7 @@ type alias ExternalGeneralMethods msg = -} type alias IsSpecific general msg = { general - | tooltip : () -> Maybe ( String, Html msg ) + | tooltip : Shared -> Maybe ( String, Html msg ) , defaultDetails : Shared -> Details } @@ -93,5 +93,6 @@ type alias IsSpecificExternal general msg = -} type alias IsGeneralExternal rest = { rest - | empty : () -> External + | empty : Shared -> External + , id : () -> String } diff --git a/client/src/elm/MassiveDecks/Card/Source/Model.elm b/client/src/elm/MassiveDecks/Card/Source/Model.elm index 55d5f559..cca3a485 100644 --- a/client/src/elm/MassiveDecks/Card/Source/Model.elm +++ b/client/src/elm/MassiveDecks/Card/Source/Model.elm @@ -1,11 +1,15 @@ module MassiveDecks.Card.Source.Model exposing - ( Details + ( BuiltInDeck + , BuiltInInfo + , Details , External(..) + , Info , LoadFailureReason(..) , Source(..) , Summary ) +import MassiveDecks.Card.Source.BuiltIn.Model as BuiltIn import MassiveDecks.Card.Source.Cardcast.Model as Cardcast @@ -31,7 +35,8 @@ sources are more limited and specific. -} type External - = Cardcast Cardcast.PlayCode + = BuiltIn BuiltIn.Id + | Cardcast Cardcast.PlayCode {-| A summary of the contents of the source deck. @@ -56,3 +61,19 @@ type alias Details = type LoadFailureReason = SourceFailure | NotFound + + +{-| Information about what sources are available from the server. +-} +type alias Info = + { builtIn : Maybe BuiltInInfo + , cardcast : Bool + } + + +type alias BuiltInInfo = + { decks : List BuiltInDeck } + + +type alias BuiltInDeck = + { name : String, id : BuiltIn.Id } diff --git a/client/src/elm/MassiveDecks/Messages.elm b/client/src/elm/MassiveDecks/Messages.elm index 1c087dd6..2d43ff13 100644 --- a/client/src/elm/MassiveDecks/Messages.elm +++ b/client/src/elm/MassiveDecks/Messages.elm @@ -1,5 +1,6 @@ module MassiveDecks.Messages exposing (Msg(..)) +import MassiveDecks.Card.Source.Model as Source import MassiveDecks.Cast.Model as Cast import MassiveDecks.Error.Messages as Error import MassiveDecks.Notifications.Model as Notifications @@ -27,5 +28,6 @@ type Msg | UpdateToken Lobby.Auth | CastStatusUpdate Cast.Status | RemoteCommand Cast.RemoteControlCommand + | UpdateSources Source.Info | Refresh | BlockedExternalUrl diff --git a/client/src/elm/MassiveDecks/Model.elm b/client/src/elm/MassiveDecks/Model.elm index 1a23c3dd..b1cb9686 100644 --- a/client/src/elm/MassiveDecks/Model.elm +++ b/client/src/elm/MassiveDecks/Model.elm @@ -4,6 +4,7 @@ module MassiveDecks.Model exposing ) import Browser.Navigation as Navigation +import MassiveDecks.Card.Source.Model as Sources import MassiveDecks.Cast.Model as Cast import MassiveDecks.Notifications.Model as Notifications import MassiveDecks.Settings.Model as Settings exposing (Settings) @@ -23,6 +24,7 @@ type alias Shared = , speech : Speech.Model , notifications : Notifications.Model , remoteMode : Bool + , sources : Sources.Info } diff --git a/client/src/elm/MassiveDecks/Models/Decoders.elm b/client/src/elm/MassiveDecks/Models/Decoders.elm index 84349934..ec68ae14 100644 --- a/client/src/elm/MassiveDecks/Models/Decoders.elm +++ b/client/src/elm/MassiveDecks/Models/Decoders.elm @@ -16,6 +16,7 @@ module MassiveDecks.Models.Decoders exposing , remoteControlCommand , revealingRound , settings + , sourceInfo , tokenValidity , userId , userSummary @@ -28,6 +29,7 @@ import Json.Patch import MassiveDecks.Card.Model as Card exposing (Call, Response) import MassiveDecks.Card.Parts as Parts exposing (Part, Parts) import MassiveDecks.Card.Play as Play exposing (Play) +import MassiveDecks.Card.Source.BuiltIn.Model as BuiltIn import MassiveDecks.Card.Source.Cardcast.Model as Cardcast import MassiveDecks.Card.Source.Model as Source exposing (Source) import MassiveDecks.Cast.Model as Cast @@ -54,6 +56,26 @@ import MassiveDecks.User as User exposing (User) import Set exposing (Set) +sourceInfo : Json.Decoder Source.Info +sourceInfo = + Json.succeed Source.Info + |> Json.optional "builtIn" (builtInInfo |> Json.map Just) Nothing + |> Json.optional "cardcast" Json.bool False + + +builtInInfo : Json.Decoder Source.BuiltInInfo +builtInInfo = + Json.succeed Source.BuiltInInfo + |> Json.required "decks" (Json.list builtInDeck) + + +builtInDeck : Json.Decoder Source.BuiltInDeck +builtInDeck = + Json.succeed Source.BuiltInDeck + |> Json.required "name" Json.string + |> Json.required "id" (Json.string |> Json.map BuiltIn.Id) + + unknownValue : String -> String -> Json.Decoder a unknownValue name value = ("Unknown " ++ name ++ ": \"" ++ value ++ "\".") |> Json.fail @@ -182,6 +204,9 @@ externalSource = externalSourceByName : String -> Json.Decoder Source.External externalSourceByName name = case name of + "BuiltIn" -> + Json.field "id" Json.string |> Json.map (BuiltIn.Id >> Source.BuiltIn) + "Cardcast" -> Json.field "playCode" Json.string |> Json.map (Cardcast.playCode >> Source.Cardcast) @@ -869,32 +894,60 @@ parts = part : Json.Decoder Part part = Json.oneOf - [ Json.string |> Json.map Parts.Text - , transform |> Json.map Parts.Slot + [ Json.string |> Json.map (\t -> Parts.Text t Parts.NoStyle) + , styled + , slot ] +slot : Json.Decoder Parts.Part +slot = + Json.succeed Parts.Slot + |> Json.optional "transform" transform Parts.NoTransform + |> Json.optional "style" style Parts.NoStyle + + +styled : Json.Decoder Parts.Part +styled = + Json.succeed Parts.Text + |> Json.required "text" Json.string + |> Json.optional "style" style Parts.NoStyle + + transform : Json.Decoder Parts.Transform transform = - Json.maybe (Json.field "transform" Json.string) |> Json.andThen transformByName + Json.string |> Json.andThen transformByName + + +transformByName : String -> Json.Decoder Parts.Transform +transformByName name = + case name of + "UpperCase" -> + Json.succeed Parts.UpperCase + + "Capitalize" -> + Json.succeed Parts.Capitalize + + _ -> + unknownValue "transform" name -transformByName : Maybe String -> Json.Decoder Parts.Transform -transformByName maybeName = - case maybeName of - Nothing -> - Json.succeed Parts.Stay +style : Json.Decoder Parts.Style +style = + Json.string |> Json.andThen styleByName - Just name -> - case name of - "UpperCase" -> - Json.succeed Parts.UpperCase - "Capitalize" -> - Json.succeed Parts.Capitalize +styleByName : String -> Json.Decoder Parts.Style +styleByName name = + case name of + "Em" -> + Json.succeed Parts.Em - _ -> - unknownValue "transform" name + "Strong" -> + Json.succeed Parts.Strong + + _ -> + unknownValue "style" name round : Json.Decoder Round diff --git a/client/src/elm/MassiveDecks/Models/Encoders.elm b/client/src/elm/MassiveDecks/Models/Encoders.elm index 914e4dda..c13de753 100644 --- a/client/src/elm/MassiveDecks/Models/Encoders.elm +++ b/client/src/elm/MassiveDecks/Models/Encoders.elm @@ -23,6 +23,7 @@ module MassiveDecks.Models.Encoders exposing import Dict import Json.Encode as Json +import MassiveDecks.Card.Source.BuiltIn.Model as BuiltIn import MassiveDecks.Card.Source.Cardcast.Model as Cardcast import MassiveDecks.Card.Source.Model as Source exposing (Source) import MassiveDecks.Cast.Model as Cast @@ -326,6 +327,9 @@ source s = Source.Cardcast (Cardcast.PlayCode playCode) -> Json.object [ ( "source", "Cardcast" |> Json.string ), ( "playCode", playCode |> Json.string ) ] + Source.BuiltIn (BuiltIn.Id id) -> + Json.object [ ( "source", "BuiltIn" |> Json.string ), ( "id", id |> Json.string ) ] + language : Language -> Json.Value language l = diff --git a/client/src/elm/MassiveDecks/Pages/Lobby.elm b/client/src/elm/MassiveDecks/Pages/Lobby.elm index 29bf841a..cefd80e5 100644 --- a/client/src/elm/MassiveDecks/Pages/Lobby.elm +++ b/client/src/elm/MassiveDecks/Pages/Lobby.elm @@ -82,16 +82,16 @@ init shared r auth = ] in fallbackAuth - |> Maybe.map (initWithAuth r >> Route.Continue) + |> Maybe.map (initWithAuth shared r >> Route.Continue) |> Maybe.withDefault (Route.Redirect (Route.Start { section = Start.Join (Just r.gameCode) })) -initWithAuth : Route -> Auth -> ( Model, Cmd msg ) -initWithAuth r auth = +initWithAuth : Shared -> Route -> Auth -> ( Model, Cmd msg ) +initWithAuth shared r auth = ( { route = r , auth = auth , lobby = Nothing - , configure = Configure.init + , configure = Configure.init shared , notificationId = 0 , notifications = [] , inviteDialogOpen = False diff --git a/client/src/elm/MassiveDecks/Pages/Lobby/Configure.elm b/client/src/elm/MassiveDecks/Pages/Lobby/Configure.elm index 7b976c12..004ecc8b 100644 --- a/client/src/elm/MassiveDecks/Pages/Lobby/Configure.elm +++ b/client/src/elm/MassiveDecks/Pages/Lobby/Configure.elm @@ -56,11 +56,11 @@ import Weightless as Wl import Weightless.Attributes as WlA -init : Model -init = +init : Shared -> Model +init shared = { localConfig = default , tab = Decks - , decks = Decks.init + , decks = Decks.init shared , privacy = Privacy.init , timeLimits = TimeLimits.init , rules = Rules.init diff --git a/client/src/elm/MassiveDecks/Pages/Lobby/Configure/Decks.elm b/client/src/elm/MassiveDecks/Pages/Lobby/Configure/Decks.elm index 93db89d8..d5d8a2e4 100644 --- a/client/src/elm/MassiveDecks/Pages/Lobby/Configure/Decks.elm +++ b/client/src/elm/MassiveDecks/Pages/Lobby/Configure/Decks.elm @@ -43,9 +43,9 @@ componentById id = all -init : Model -init = - { toAdd = Source.default } +init : Shared -> Model +init shared = + { toAdd = Source.default shared } default : Config @@ -66,7 +66,7 @@ update shared msg model = ( settings, settingsCmd ) = Settings.onAddDeck source shared.settings in - ( { model | toAdd = Source.emptyMatching source } + ( { model | toAdd = Source.emptyMatching shared source } , { shared | settings = settings } , Cmd.batch [ Actions.configure (addDeck source), settingsCmd ] ) @@ -226,7 +226,7 @@ submitDeckAction wrap existing deckToAdd = let potentialProblems = if List.any (getSource >> Source.equals deckToAdd) existing then - [ Strings.DeckAlreadyAdded |> Message.error ] + [ Strings.DeckAlreadyAdded |> Message.info ] else Source.problems deckToAdd @@ -308,7 +308,7 @@ name wrap shared canEdit index source loading maybeError details = |> Maybe.justIf canEdit ( maybeId, tooltip ) = - source |> Source.Ex |> Source.tooltip |> Maybe.decompose + source |> Source.Ex |> Source.tooltip shared |> Maybe.decompose attrs = maybeId |> Maybe.map (\id -> [ HtmlA.id id ]) |> Maybe.withDefault [] diff --git a/client/src/elm/MassiveDecks/Pages/Start.elm b/client/src/elm/MassiveDecks/Pages/Start.elm index 66186783..2274d34f 100644 --- a/client/src/elm/MassiveDecks/Pages/Start.elm +++ b/client/src/elm/MassiveDecks/Pages/Start.elm @@ -585,8 +585,13 @@ examplePick2 : Card.Call examplePick2 = Card.call (Parts.unsafeFromList - [ [ Parts.Slot Parts.Stay, Parts.Text " + ", Parts.Slot Parts.Stay ] - , [ Parts.Text " = ", Parts.Slot Parts.Stay ] + [ [ Parts.Slot Parts.NoTransform Parts.NoStyle + , Parts.Text " + " Parts.NoStyle + , Parts.Slot Parts.NoTransform Parts.NoStyle + ] + , [ Parts.Text " = " Parts.NoStyle + , Parts.Slot Parts.NoTransform Parts.NoStyle + ] ] ) "" diff --git a/client/src/elm/MassiveDecks/Pages/Start/LobbyBrowser/Messages.elm b/client/src/elm/MassiveDecks/Pages/Start/LobbyBrowser/Messages.elm index b9c6fe2b..457d443b 100644 --- a/client/src/elm/MassiveDecks/Pages/Start/LobbyBrowser/Messages.elm +++ b/client/src/elm/MassiveDecks/Pages/Start/LobbyBrowser/Messages.elm @@ -5,4 +5,4 @@ import MassiveDecks.Requests.HttpData.Messages as HttpData type Msg - = SummaryUpdate (HttpData.Msg () (List Summary)) + = SummaryUpdate (HttpData.Msg Never (List Summary)) diff --git a/client/src/elm/MassiveDecks/Pages/Start/LobbyBrowser/Model.elm b/client/src/elm/MassiveDecks/Pages/Start/LobbyBrowser/Model.elm index ab07829a..beea534b 100644 --- a/client/src/elm/MassiveDecks/Pages/Start/LobbyBrowser/Model.elm +++ b/client/src/elm/MassiveDecks/Pages/Start/LobbyBrowser/Model.elm @@ -12,7 +12,7 @@ import MassiveDecks.Requests.HttpData.Model exposing (HttpData) {-| The model for the lobby browser. -} type alias Model = - HttpData () (List Summary) + HttpData Never (List Summary) {-| An external summary of a lobby. diff --git a/client/src/elm/MassiveDecks/Pages/Start/Messages.elm b/client/src/elm/MassiveDecks/Pages/Start/Messages.elm index 57940958..824953f6 100644 --- a/client/src/elm/MassiveDecks/Pages/Start/Messages.elm +++ b/client/src/elm/MassiveDecks/Pages/Start/Messages.elm @@ -9,7 +9,7 @@ import MassiveDecks.Requests.HttpData.Messages as HttpData type Msg = GameCodeChanged String | NameChanged String - | StartGame (HttpData.Msg () Lobby.Auth) + | StartGame (HttpData.Msg Never Lobby.Auth) | JoinGame (HttpData.Msg MdError Lobby.Auth) | LobbyBrowserMsg LobbyBrowser.Msg | PasswordChanged String diff --git a/client/src/elm/MassiveDecks/Pages/Start/Model.elm b/client/src/elm/MassiveDecks/Pages/Start/Model.elm index fac57f94..f910037f 100644 --- a/client/src/elm/MassiveDecks/Pages/Start/Model.elm +++ b/client/src/elm/MassiveDecks/Pages/Start/Model.elm @@ -20,7 +20,7 @@ type alias Model = , lobbies : LobbyBrowser.Model , name : String , gameCode : Maybe GameCode - , newLobbyRequest : HttpData () Lobby.Auth + , newLobbyRequest : HttpData Never Lobby.Auth , joinLobbyRequest : HttpData MdError Lobby.Auth , password : Maybe String , overlay : Maybe MdString diff --git a/client/src/elm/MassiveDecks/Requests/Api.elm b/client/src/elm/MassiveDecks/Requests/Api.elm index 6aedb4bb..bcae42b2 100644 --- a/client/src/elm/MassiveDecks/Requests/Api.elm +++ b/client/src/elm/MassiveDecks/Requests/Api.elm @@ -3,11 +3,13 @@ module MassiveDecks.Requests.Api exposing , joinLobby , lobbySummaries , newLobby + , sourceInfo ) import Dict exposing (Dict) import Http import Json.Decode as Json +import MassiveDecks.Card.Source.Model as Source import MassiveDecks.Error.Model as Error import MassiveDecks.Models.Decoders as Decoders import MassiveDecks.Models.Encoders as Encoders @@ -25,7 +27,7 @@ import Url.Builder {-| List the public lobbies. -} -lobbySummaries : (Request.Response () (List LobbyBrowser.Summary) -> msg) -> Request msg +lobbySummaries : (Request.Response Never (List LobbyBrowser.Summary) -> msg) -> Request msg lobbySummaries msg = { method = "GET" , headers = [] @@ -39,7 +41,7 @@ lobbySummaries msg = {-| Create a new lobby. -} -newLobby : (Request.Response () Lobby.Auth -> msg) -> Start.LobbyCreation -> Request msg +newLobby : (Request.Response Never Lobby.Auth -> msg) -> Start.LobbyCreation -> Request msg newLobby msg creation = { method = "POST" , headers = [] @@ -51,6 +53,8 @@ newLobby msg creation = } +{-| Join a lobby. +-} joinLobby : (Request.Response MdError Lobby.Auth -> msg) -> GameCode -> User.Registration -> Request msg joinLobby msg gameCode registration = { method = "POST" @@ -63,7 +67,9 @@ joinLobby msg gameCode registration = } -checkAlive : (Request.Response () (Dict Lobby.Token Bool) -> msg) -> List Lobby.Token -> Request msg +{-| Check if previously joined lobbies are still going. +-} +checkAlive : (Request.Response Never (Dict Lobby.Token Bool) -> msg) -> List Lobby.Token -> Request msg checkAlive msg tokens = { method = "POST" , headers = [] @@ -75,6 +81,20 @@ checkAlive msg tokens = } +{-| Find out what sources the server offers. +-} +sourceInfo : (Request.Response Never Source.Info -> msg) -> Request msg +sourceInfo msg = + { method = "GET" + , headers = [] + , url = url [ "sources" ] + , body = Http.emptyBody + , expect = Request.expectResponse msg noError Decoders.sourceInfo + , timeout = Nothing + , tracker = Nothing + } + + {- Private -} @@ -92,6 +112,6 @@ url path = Url.Builder.absolute ([ "api" ] ++ path) [] -noError : Json.Decoder () +noError : Json.Decoder Never noError = - Json.succeed () + Json.fail "No specific errors are expected for this request." diff --git a/client/src/elm/MassiveDecks/Strings.elm b/client/src/elm/MassiveDecks/Strings.elm index 17ffab6b..617cb367 100644 --- a/client/src/elm/MassiveDecks/Strings.elm +++ b/client/src/elm/MassiveDecks/Strings.elm @@ -204,6 +204,7 @@ type MdString | Cardcast -- The name of the Cardcast service. | CardcastPlayCode -- A term referring to the play code that identifies a deck on Cardcast. | CardcastEmptyPlayCode -- A description of the problem of the entered Cardcast play code being empty. + | BuiltIn -- A term referring to decks of cards that are provided by this instance of the game. | APlayer -- A short description of a generic player in the game in the context of being the author of a card. | DeckAlreadyAdded -- A description of the problem of the deck already being added to the game configuration. | ConfigureDecks -- A name for the section of the configuration screen for changing the decks for the game. diff --git a/client/src/elm/MassiveDecks/Strings/Languages/En.elm b/client/src/elm/MassiveDecks/Strings/Languages/En.elm index 097531e5..8ddae75b 100644 --- a/client/src/elm/MassiveDecks/Strings/Languages/En.elm +++ b/client/src/elm/MassiveDecks/Strings/Languages/En.elm @@ -4,7 +4,7 @@ module MassiveDecks.Strings.Languages.En exposing (pack) This is the primary language, strings here are the canonical representation, and are suitable to translate from. -} -import MassiveDecks.Card.Source.Cardcast.Model as Cardcast +import MassiveDecks.Card.Source.BuiltIn.Model as BuiltIn import MassiveDecks.Card.Source.Model as Source import MassiveDecks.Strings exposing (MdString(..)) import MassiveDecks.Strings.Translation as Translation exposing (Result(..)) @@ -15,7 +15,7 @@ pack = { code = "en" , name = English , translate = translate - , recommended = "CAHBS" |> Cardcast.playCode |> Source.Cardcast + , recommended = "cah-base-en" |> BuiltIn.Id |> Source.BuiltIn } @@ -714,6 +714,9 @@ translate mdString = CardcastEmptyPlayCode -> [ Text "Enter a ", Ref CardcastPlayCode, Text " for the deck you want to add." ] + BuiltIn -> + [ Text "Built-in" ] + APlayer -> [ Text "A Player" ] diff --git a/client/src/elm/MassiveDecks/Strings/Languages/It.elm b/client/src/elm/MassiveDecks/Strings/Languages/It.elm index e26536d6..ac1ea671 100644 --- a/client/src/elm/MassiveDecks/Strings/Languages/It.elm +++ b/client/src/elm/MassiveDecks/Strings/Languages/It.elm @@ -1,8 +1,8 @@ module MassiveDecks.Strings.Languages.It exposing (pack) -{- General Italian translation -} +{- Italian translation -} -import MassiveDecks.Card.Source.Cardcast.Model as Cardcast +import MassiveDecks.Card.Source.BuiltIn.Model as BuiltIn import MassiveDecks.Card.Source.Model as Source import MassiveDecks.Strings exposing (MdString(..)) import MassiveDecks.Strings.Translation as Translation exposing (Result(..)) @@ -13,7 +13,7 @@ pack = { code = "it" , name = Italian , translate = translate - , recommended = "CAHBS" |> Cardcast.playCode |> Source.Cardcast + , recommended = "cah-base-en" |> BuiltIn.Id |> Source.BuiltIn } @@ -482,9 +482,11 @@ translate mdString = [ Text (String.fromInt numberOfCards) ] -- Lobby + -- TODO: Translate LobbyNameLabel -> [ Text "Game Name" ] + -- TODO: Translate DefaultLobbyName { owner } -> [ Text owner, Text "'s Game" ] @@ -730,6 +732,10 @@ translate mdString = CardcastEmptyPlayCode -> [ Text "Inserisci il ", Ref CardcastPlayCode, Text " per il mazzo che vuoi aggiungere." ] + -- TODO: Translate + BuiltIn -> + [ Text "Built-in" ] + APlayer -> [ Text "Un giocatore" ] diff --git a/client/src/elm/MassiveDecks/Strings/Languages/PtBR.elm b/client/src/elm/MassiveDecks/Strings/Languages/PtBR.elm index 4facfc82..48eeb2cd 100644 --- a/client/src/elm/MassiveDecks/Strings/Languages/PtBR.elm +++ b/client/src/elm/MassiveDecks/Strings/Languages/PtBR.elm @@ -3,7 +3,7 @@ module MassiveDecks.Strings.Languages.PtBR exposing (pack) {-| Brazilian Portuguese translation. -} -import MassiveDecks.Card.Source.Cardcast.Model as Cardcast +import MassiveDecks.Card.Source.BuiltIn.Model as BuiltIn import MassiveDecks.Card.Source.Model as Source import MassiveDecks.Strings exposing (MdString(..)) import MassiveDecks.Strings.Translation as Translation exposing (Result(..)) @@ -14,7 +14,7 @@ pack = { code = "ptBR" , name = BrazilianPortuguese , translate = translate - , recommended = "CAHBS" |> Cardcast.playCode |> Source.Cardcast + , recommended = "cah-base-en" |> BuiltIn.Id |> Source.BuiltIn } @@ -482,9 +482,11 @@ translate mdString = [ Text (String.fromInt numberOfCards) ] -- Lobby + -- TODO: Translate LobbyNameLabel -> [ Text "Game Name" ] + -- TODO: Translate DefaultLobbyName { owner } -> [ Text owner, Text "'s Game" ] @@ -730,6 +732,10 @@ translate mdString = CardcastEmptyPlayCode -> [ Text "Digite um ", Ref CardcastPlayCode, Text " para o deck que você queira adicionar." ] + -- TODO: Translate + BuiltIn -> + [ Text "Built-in" ] + APlayer -> [ Text "Um jogador" ] diff --git a/client/src/scss/cards.scss b/client/src/scss/cards.scss index 3aed914a..005903ae 100644 --- a/client/src/scss/cards.scss +++ b/client/src/scss/cards.scss @@ -38,7 +38,7 @@ align-content: flex-start; justify-content: flex-start; - span { + span, em, strong { white-space: pre-wrap; overflow-wrap: break-word; word-break: break-all; @@ -47,7 +47,7 @@ .slot { display: contents; - span { + span, em, strong { text-decoration: underline; } diff --git a/client/src/scss/pages/lobby/configure.scss b/client/src/scss/pages/lobby/configure.scss index c88810fd..51f720a5 100644 --- a/client/src/scss/pages/lobby/configure.scss +++ b/client/src/scss/pages/lobby/configure.scss @@ -124,6 +124,11 @@ --input-padding-top-bottom: 0.9em; } +#built-in-selector { + height: 100%; + --input-padding-top-bottom: 0.9em; +} + .house-rules { margin-top: 2em; } diff --git a/deployment/postgres/config.json5 b/deployment/postgres/config.json5 index d600b731..267bc2c4 100644 --- a/deployment/postgres/config.json5 +++ b/deployment/postgres/config.json5 @@ -16,6 +16,37 @@ basePath: "", // This will be overwritten by the environment variable MD_BASE_PATH, if set. + // What decks players have access to. + // Note at least one source must be enabled. + sources: { + // Decks stored as static files. + // Note that the client has references to these built-in decks as defaults, so if you disable this or change the + // decks, you will need to make changes there (they are defined in the language definitions). + builtIn: { + // The directory to look for decks in. + basePath: "decks", + + // Ids for decks in the order they will be presented to the user. + // The game will look for these in the directory given by basePath above, and will expect them to have the + // .deck.json5 extension. + decks: [ + "cah-base-en" + ] + }, + + // Allows players to load decks from Cardcast via their API. https://www.cardcastgame.com/ + cardcast: { + // How long to wait for a response from the Cardcast API service before giving up and telling the user there is a + // problem. + timeout: "PT10S", + + // The number of connections the server can make to Cardcast at one time. + // Please remember this is a service provided by Cardcast, and you should make best efforts to ensure you don't + // hit them too hard, including not setting this too high. + simultaneousConnections: 1 + } + }, + // Timeouts determine how long the server waits before taking certain actions. // These values are all ISO 8601 durations. timeouts: { diff --git a/server/config.json5 b/server/config.json5 index c4e3ef0e..4ee99fe4 100644 --- a/server/config.json5 +++ b/server/config.json5 @@ -16,6 +16,37 @@ basePath: "", // This will be overwritten by the environment variable MD_BASE_PATH, if set. + // What decks players have access to. + // Note at least one source must be enabled. + sources: { + // Decks stored as static files. + // Note that the client has references to these built-in decks as defaults, so if you disable this or change the + // decks, you will need to make changes there (they are defined in the language definitions). + builtIn: { + // The directory to look for decks in. + basePath: "decks", + + // Ids for decks in the order they will be presented to the user. + // The game will look for these in the directory given by basePath above, and will expect them to have the + // .deck.json5 extension. + decks: [ + "cah-base-en" + ] + }, + + // Allows players to load decks from Cardcast via their API. https://www.cardcastgame.com/ + cardcast: { + // How long to wait for a response from the Cardcast API service before giving up and telling the user there is a + // problem. + timeout: "PT10S", + + // The number of connections the server can make to Cardcast at one time. + // Please remember this is a service provided by Cardcast, and you should make best efforts to ensure you don't + // hit them too hard, including not setting this too high. + simultaneousConnections: 1 + } + }, + // Timeouts determine how long the server waits before taking certain actions. // These values are all ISO 8601 durations. timeouts: { diff --git a/server/decks/cah-base-en.deck.json5 b/server/decks/cah-base-en.deck.json5 new file mode 100644 index 00000000..3cda7f9b --- /dev/null +++ b/server/decks/cah-base-en.deck.json5 @@ -0,0 +1,1301 @@ +// Cards Against Humanity, https://cardsagainsthumanity.com/ +// Used under a Creative Commons BY-NC-SA 2.0 license, https://creativecommons.org/licenses/by-nc-sa/2.0/ +{ + name: "Cards Against Humanity", + "calls": [ + [ + [ + "Hey Reddit! I’m ", + { + "transform": "Capitalize" + }, + "." + ], + [ + "Ask me anything." + ] + ], + [ + [ + "Introducing X-treme Baseball!" + ], + [ + "It’s like baseball, but with ", + {}, + "!" + ] + ], + [ + [ + "What is Batman’s guilty pleasure?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "TSA guidelines now prohibit ", + {}, + " on airplanes." + ] + ], + [ + [ + "Next from J.K. Rowling: ", + { + text: "Harry Potter and the Chamber of ", + style: "Em", + }, + { + "transform": "Capitalize", + "style": "Em", + }, + "." + ] + ], + [ + [ + "That’s right, I killed ", + {}, + "." + ], + [ + "How, you ask?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "I’m sorry professor, but I couldn’t complete my homework because of ", + {}, + "." + ] + ], + [ + [ + "And the Academy Award for ", + {}, + " goes to ", + {}, + "." + ] + ], + [ + [ + "Dude, do not go in that bathroom." + ], + [ + "There’s ", + {}, + " in there." + ] + ], + [ + [ + "How did I lose my virginity?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "It’s a pity that kids these days are all getting involved with ", + {}, + "." + ] + ], + [ + [ + "Step 1: ", + { + "transform": "Capitalize" + }, + "." + ], + [ + "Step 2: ", + { + "transform": "Capitalize" + }, + "." + ], + [ + "Step 3: Profit." + ] + ], + [ + [ + { + "transform": "Capitalize" + }, + "." + ], + [ + "Becha can’t have just one!" + ] + ], + [ + [ + "Kids, I don’t need drugs to get high. I’m high on ", + {}, + "." + ] + ], + [ + [ + "For my next trick, I will pull ", + {}, + " out of ", + {}, + "." + ] + ], + [ + [ + "While the United States raced the Soviet Union to the moon, the Mexican government funneled millions of pesos into research on ", + {}, + "." + ] + ], + [ + [ + "In the new Disney Channel Original Movie, Hannah Montana struggles with ", + {}, + " for the first time." + ] + ], + [ + [ + "What’s my secret power?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "I’m going on a cleanse this week. Nothing but kale juice and ", + {}, + "." + ] + ], + [ + [ + { + "transform": "Capitalize" + }, + " + ", + { + "transform": "Capitalize" + } + ], + [ + " = ", + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "When Pharaoh remained unmoved, Moses called down a Plague of ", + {}, + "." + ] + ], + [ + [ + "Just once, I’d like to hear you say “Thanks, Mom. Thanks for ", + {}, + ".”" + ] + ], + [ + [ + "Daddy, why is mommy crying?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "When I was tripping on acid, ", + {}, + " turned into ", + {}, + "." + ] + ], + [ + [ + "50% of all marriages end in ", + {}, + "." + ] + ], + [ + [ + "My fellow Americans: Before this decade is out, we ", + { + text: "will", + style: "Em" + }, + ", have ", + {}, + " on the moon." + ] + ], + [ + [ + "This season at Steppenwolf, Samuel Beckett’s classic existential play: ", + { + text: "Waiting for", + style: "Em" + }, + { + style: "Em" + }, + "." + ] + ], + [ + [ + "Instead of coal, Santa now gives the bad children ", + {}, + "." + ] + ], + [ + [ + "Life for American Indians was forever changed when the White Man introduced them to ", + {}, + "." + ] + ], + [ + [ + "What’s Teach for America using to inspire inner city students to succeed?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "Maybe she’s born with it." + ], + [ + "Maybe it’s ", + {}, + "." + ] + ], + [ + [ + "What is George W. Bush thinking about right now?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "White people like ", + {}, + "." + ] + ], + [ + [ + { + "transform": "Capitalize" + }, + " is a slippery slope that leads to ", + {}, + "." + ] + ], + [ + [ + "Why do I hurt all over?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "A romantic, candlelit dinner would be incomplete without ", + {}, + "." + ] + ], + [ + [ + "Just saw this upsetting video! Please retweet!!" + ], + [ + "#stop", + { + "transform": "Capitalize" + } + ] + ], + [ + [ + "Fun tip! When your man asks you to go down on him, try surprising him with ", + {}, + " instead." + ] + ], + [ + [ + "The class field trip was completely ruined by ", + {}, + "." + ] + ], + [ + [ + "What’s a girl’s best friend?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "Dear Abby, I’m having some trouble with ", + {}, + " and would like your advice." + ] + ], + [ + [ + "In M. Night Shyamalan’s new movie, Bruce Willis discovers that ", + {}, + " had really been ", + {}, + " all along." + ] + ], + [ + [ + "When I am President of the United States, I will create the Department of ", + {}, + "." + ] + ], + [ + [ + "What are my parents hiding from me?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "What never fails to liven up the party?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "IF you like ", + { + "transform": "UpperCase" + }, + "," + ], + [ + "YOU MIGHT BE A REDNECK." + ] + ], + [ + [ + "Make a haiku." + ], + [ + { + "transform": "Capitalize" + }, + "," + ], + [ + { + "transform": "Capitalize" + }, + "," + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "What made my first kiss so awkward?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "Hey guys, welcome to Chili’s! Would you like to start the night off right with ", + {}, + "?" + ] + ], + [ + [ + "I got 99 problems but ", + {}, + " ain’t one." + ] + ], + [ + [ + { + "transform": "Capitalize" + }, + "." + ], + [ + "It’s a trap!" + ] + ], + [ + [ + "Bravo’s new reality show features eight washed-up celebrities living with ", + {}, + "." + ] + ], + [ + [ + "What would grandma find disturbing, yet oddly charming?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + { + "transform": "Capitalize" + }, + "." + ], + [ + "That was so metal." + ] + ], + [ + [ + "I never truly understood ", + {}, + " until I encountered ", + {}, + "." + ] + ], + [ + [ + "During sex, I like to think about ", + {}, + "." + ] + ], + [ + [ + "What ended my last relationship?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "What’s that sound?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "Uh, hey guys, I know this was my idea, but I’m having serious doubts about ", + {}, + "." + ] + ], + [ + [ + "Why am I sticky?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "I’m no doctor but I’m pretty sure what you’re suffering from is called “", + {}, + ".”" + ] + ], + [ + [ + "What’s there a ton of in heaven?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "After four platinum albums and three Grammys, it’s time to get back to my roots, to what inspired me to make music in the first place: ", + {}, + "." + ] + ], + [ + [ + "What will always get you laid?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "They said we were crazy. They said we couldn’t put ", + {}, + " inside of ", + {}, + "." + ], + [ + "They were wrong." + ] + ], + [ + [ + "Lifetime® presents “", + {}, + ": the Story of ", + {}, + ".”" + ] + ], + [ + [ + { + "transform": "Capitalize" + }, + ": kid-tested, mother-approved." + ] + ], + [ + [ + "Why can’t I sleep at night?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "What’s that smell?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "After eight years in the White House, how is Obama finally letting loose?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "This is the way the world ends" + ], + [ + "This is the way the world ends" + ], + [ + "Not with a bang but with ", + {}, + "." + ] + ], + [ + [ + "Coming to Broadway this season, ", + {}, + ": The Musical." + ] + ], + [ + [ + "Here is the church" + ], + [ + "Here is the steeple" + ], + [ + "Open the doors" + ], + [ + "And there is", + {}, + "." + ] + ], + [ + [ + "But before I kill you, Mr. Bond, I must show you ", + {}, + "." + ] + ], + [ + [ + "Studies show that lab rats navigate mazes 50% faster after being exposed to ", + {}, + "." + ] + ], + [ + [ + "What’s the next superhero/sidekick duo?" + ], + [ + { + "transform": "Capitalize" + }, + "/", + { + "transform": "Capitalize" + }, + "." + ] + ], + [ + [ + "Next on ESPN2, the World Series of ", + {}, + "." + ] + ], + [ + [ + "When I am a billionaire, I shall erect a 50-foot statue to commemorate ", + {}, + "." + ] + ], + [ + [ + "Military historians remember Alexander the Great for his brilliant use of ", + {}, + " against the Persians." + ] + ], + [ + [ + "War!" + ], + [ + "What is it good for?" + ], + [ + { + "transform": "Capitalize" + }, + "." + ] + ] + ], + "responses": [ + "silence", + "the illusion of choice ina late-stage capitalist society", + "many bats", + "famine", + "flesh-eating bacteria", + "flying sex snakes", + "not giving a shit about the Third World", + "magnets", + "shapeshifters", + "seeing what happens when you lock people in a room with hungry seagulls", + "a crucifixion", + "Jennifer Lawrence", + "72 virgins", + "a live studio audience", + "a time travel paradox", + "authentic Mexican cuisine", + "doing crimes", + "synergistic management solutions", + "crippling debt", + "daddy issues", + "used panties", + "a fart so powerful that it wakes the giants from their thousand-year slumber", + "former president George W. Bush", + "full frontal nudity", + "covering myself with Parmesan cheese and chili flakes because I am pizza", + "laying an egg", + "getting naked and watching Nickelodeon", + "pretending to care", + "having big dreams but no realistic way to achieve them", + "seeing Grandma naked", + "boogers", + "the inevitable heat death of the universe", + "the miracle of childbirth", + "the rapture", + "whipping it out", + "white privilege", + "emerging from the sea and rampaging through Tokyo", + "the Hamburglar", + "AXE Body Spray", + "the Blood of Christ", + "soft, kissy missionary sex", + "BATMAN!", + "agriculture", + "barely making $25,000 a year", + "natural selection", + "coat hanger abortions", + "sex with Patrick Stewart", + "my abusive boyfriend who really isn’t so bad once you get to know him", + "prescription pain killers", + "swooping", + "mansplaining", + "a homoerotic volleyball montage", + "Alexandria Ocasio-Cortez", + "putting things where they go", + "fragile masculinity", + "all-you-can-eat shrimp for $8.99", + "an old guy who’s almost dead", + "Kanye West", + "hot cheese", + "raptor attacks", + "seven dead and three in critical condition", + "smegma", + "alcoholism", + "a middle-aged man on roller skates", + "looking in the mirror, applying lipstick, and whispering “tonight, you will have sex with Tom Cruise”", + "bingeing and purging", + "an oversized lollipop", + "self-loathing", + "sitting on my face and telling me I’m garbage", + "half-assed foreplay", + "the Holy Bible", + "german dungeon porn", + "being on fire", + "teenage pregnancy", + "Gandhi", + "your weird brother", + "a Fleshlight®", + "a pyramid of severed heads", + "an erection that lasts longer than four hours", + "a three-way with my wife and Shaquille O’Neal", + "the past", + "my genitals", + "an endless stream of diarrhea", + "science", + "not reciprocating oral sex", + "flightless birds", + "a good sniff", + "50,000 volts straight to the nipples", + "a balanced breakfast", + "dead birds everywhere", + "the arrival of the pizza", + "Permanent Orgasm-Face Disorder", + "the cool, refreshing taste of Pepsi®", + "chemical weapons", + "Oprah", + "wondering if it’s possible to get some of that salsa to go", + "bananas", + "passive aggressive Post-it notes", + "Hillary Clinton’s emails", + "switching to Geico®", + "peeing a little bit", + "wet dreams", + "the Jews", + "my cheating son-of-a-bitch husband", + "powerful thighs", + "these hoes", + "the only gay person in a hundred miles", + "having sex for the first time", + "Donald J. Trump", + "kissing grandma on the forehead and turning off her life support", + "sexual tension", + "an AR-15 assault rifle", + "my good bra", + "punching a congressman in the face", + "Fancy Feast®", + "being rich", + "sweet, sweet vengeance", + "Republicans", + "sniffing and kissing my feet", + "a much younger woman", + "poverty", + "kamikaze pilots", + "committing suicide", + "the homosexual agenda", + "a Mexican", + "a falcon with a cap on its head", + "wizard music", + "the Kool-Aid Man", + "Juuling", + "free samples", + "hurting those closest to me", + "doing the right thing", + "the Three-Fifths Compromise", + "lactation", + "world peace", + "shutting up so I can watch the game", + "eating a hard boiled egg out of my husband’s asshole", + "RoboCop", + "one titty hanging out", + "Justin Bieber", + "Oompa-Loompas", + "inappropriate yodeling", + "puberty", + "ghosts", + "50 mg of Zoloft daily", + "fucking my sister", + "braiding three penises into a Twizzler", + "vigorous jazz hands", + "getting fingered", + "my Uber driver, Pavel", + "GoGurt®", + "police brutality", + "filling my briefcase with business stuff", + "preteens", + "my fat daughter", + "rap music", + "fading away into nothingness", + "Darth Vader", + "a sad handjob", + "exactly what you’d expect", + "expecting a burp and vomiting on the floor", + "Adderall®", + "the Red Hot Chili Peppers", + "sideboob", + "an octopus giving seven handjobs and smoking a cigarette", + "my neck, my back, my pussy, and my crack", + "J. D. Power and his associates", + "mouth herpes", + "sperm whales", + "women of color", + "men discussing their feelings in an emotionally healthy way", + "incest", + "Pac-Man uncontrollably guzzling cum", + "casually suggesting a threesome", + "running out of semen", + "god", + "the wonders of the Orient", + "sexual peeing", + "emotions", + "licking things to claim them as your own", + "jobs", + "the placenta", + "spontaneous human combustion", + "the Bachelorette season finale", + "throwing grapes at a man until he loses touch with reality", + "establishing dominance", + "finger painting", + "old-people smell", + "getting crushed by a vending machine", + "my inner demons", + "a Super Soaker™ full of cat pee", + "Aaron Burr", + "cuddling", + "however much weed $20 can buy", + "battlefield amputations", + "Spaghetti? Again?", + "Ronald Reagan", + "a disappointing birthday party", + "nachos for the table", + "becoming a blueberry", + "a tiny horse", + "William Shatner", + "selling crack to children", + "an M. Night Shyamalan plot twist", + "brown people", + "mutually assured destruction", + "pedophiles", + "yeast", + "how bad my daughter fucked up her dance recital", + "rectangles", + "catapults", + "poor people", + "only dating Asian women", + "the Hustle", + "the Force", + "how amazing it is to be on mushrooms", + "judging everyone", + "Kourtney, Kim, Khloe, Kendall, and Kylie", + "getting married, having a few kids, buying some stuff, retiring to Florida, and dying", + "some god damn peace and quiet", + "AIDS", + "pictures of boobs", + "strong female characters", + "some foundation, mascara, and a touch of blush", + "hospice care", + "getting really high", + "the opiod epidemic", + "penis envy", + "gay conversion therapy", + "Ruth Bader Ginsburg brutally gaveling your penis", + "German Chancellor Angela Merkel", + "the KKK", + "a pangender octopus who roams the cosmos in search of love", + "meth", + "serfdom", + "holding down a child and farting all over him", + "a Bop It™", + "a whole thing of butter", + "still being a virgin", + "solving problems with violence", + "getting cummed on", + "pixelated bukkake", + "a lifetime of sadness", + "going an entire day without masturbating", + "dick pics", + "racism", + "menstrual rage", + "sunshine and rainbows", + "radical Islamic terrorism", + "huge biceps", + "my little boner", + "dry heaving", + "a gossamer stream of jizz that catches the light as it arcs through the morning air", + "executing a hostage every hour", + "the rhythms of Africa", + "breaking out into song and dance", + "leprosy", + "gloryholes", + "nipple blades", + "the heart of a child", + "puppies!", + "fellowship in Christ", + "little boy penises", + "waking up half-naked in a Denny’s parking lot", + "an older woman who knows her way around the penis", + "getting drugs off the street and into my body", + "Daniel Radcliffe’s delicious asshole", + "active listening", + "ethnic cleansing", + "itchy pussy", + "blowing my boyfriend so hard he shits", + "a fuck-ton of almonds", + "a salad for men that’s made of metal", + "waiting till marriage", + "unfathomable stupidity", + "shiny objects", + "the Devil himself", + "autocannibalism", + "erectile dysfunction", + "my collection of Japanese sex toys", + "the Pope", + "white people", + "tentacle porn", + "my bright pink fuckhole", + "how far I can get my own penis up my butt", + "having anuses for eyes", + "The penny whistle solo from “My Heart Will Go On”", + "Seppuku", + "Danny DeVito", + "the magic of live theatre", + "throwing a virgin into a volcano", + "Dwayne “The Rock” Johnson", + "accepting the way things are", + "NBA superstar LeBron James", + "listening to her problems without trying to solve them", + "therapy", + "being fat and stupid", + "pooping back and forth. Forever", + "tearing that ass up like wrapping paper on Christmas morning", + "more elephant cock than I bargained for", + "a salty surprise", + "the South", + "the violation of our most basic human rights", + "tap dancing like there’s no tomorrow", + "consensual sex", + "telling a shitty story that goes nowhere", + "a good, strong gorilla", + "seeing my father cry", + "necrophilia", + "being a woman", + "getting into a pretty bad car accident", + "Bill Nye the Science Guy", + "black people", + "The Boy Scouts of America", + "Lunchables™", + "bitches", + "some punk kid who stole my turkey sandwich", + "heartwarming orphans", + "Spirit Airlines", + "bubble butt bottom boys", + "a bowl of mayonnaise and human teeth", + "fiery poops", + "saying “I love you”", + "inserting a Mason jar into my anus", + "the true meaning of Christmas", + "some of the best rappers in the game", + "owning and operating a Chili’s franchise", + "estrogen", + "girls", + "the Russians", + "a bleached asshole", + "fucking the weatherman on live television", + "PTSD", + "dark and mysterious forces beyond our control", + "smallpox blankets", + "masturbating", + "hobos", + "queefing", + "the guys from Queer Eye", + "Cardi B.", + "Viagra®", + "soup that is too hot", + "Muhammad (Peace Be Upon Him)", + "explaining how vaginas work", + "Academy Award winner Meryl Streep", + "drinking alone", + "dick fingers", + "multiple stab wounds", + "poopy diapers", + "child abuse", + "anal beads", + "slaughtering innocent civilians", + "pulling out", + "being able to talk to elephants", + "horse meat", + "a really cool hat", + "Stalin", + "a stray pube", + "worshipping that pussy", + "completely unwarranted confidence", + "doin’ it in the butt", + "my ex-wife", + "teaching a robot to love", + "touching a pug right on his penis", + "a windmill full of corpses", + "Count Chocula", + "Vladimir Putin", + "the Patriarchy", + "the glass ceiling", + "vomiting seafood and bleeding anally", + "The American Dream", + "not wearing pants", + "my balls on your face", + "pooping in a laptop and closing it", + "dead babies", + "foreskin", + "a saxophone solo", + "Italians", + "a fetus", + "firing a rifle into the air while balls deep in a squealing hog", + "Mike Pence", + "10,000 Syrian refugees", + "forced sterilization", + "my relationship status", + "an unwanted pregnancy", + "diversity", + "a white ethnostate", + "Bees?", + "Harry Potter erotica", + "giving birth to the Antichrist", + "three dicks at the same time", + "Nazis", + "8 oz. of sweet Mexican black-tar heroin", + "what that mouth do", + "dead parents", + "object permanence", + "opposable thumbs", + "racially-biased SAT questions", + "the Great Depression", + "chainsaws for hands", + "Nicolas Cage", + "child beauty pageants", + "explosions", + "not vaccinating my children because I am stupid", + "Lena Dunham", + "huffing spray paint", + "a man on the brink of orgasm", + "repression", + "invading Poland", + "my vagina", + "assless chaps", + "murder", + "giving 110%", + "Her Majesty, Queen Elizabeth II", + "the Trail of Tears", + "memes", + "sex with animals", + "being marginalized", + "goblins", + "hope", + "liberals", + "a micropenis", + "my soul", + "a ball of earwax, semen, and toenail clippings", + "a horde of Vikings", + "hot people", + "seething with quiet resentment", + "an Oedipus complex", + "geese", + "extremely tight pants", + "Fox News", + "a little boy who won’t shut the fuck up about dinosaurs", + "making a pouty face", + "vehicular manslaughter", + "women’s suffrage", + "some guy", + "Judge Judy", + "African children", + "this month’s mass shooting", + "Barack Obama", + "illegal immigrants", + "elderly Japanese men", + "the female orgasm", + "heteronormativity", + "crumbs all over the god damn carpet", + "Arnold Schwarzenegger", + "the wifi password", + "spectacular abs", + "a bird that shits human turds", + "a mopey zoo lion", + "a bag of magic beans", + "poor life choices", + "my sex life", + "Auschwitz", + "a snapping turtle biting the tip of your penis", + "all the dudes I’ve fucked", + "the clitoris", + "the Big Bang", + "land mines", + "The entire Mormon Tabernacle Choir", + "a micropig wearing a tiny raincoat and booties", + "penis breath", + "jerking off into a pool of children’s tears", + "man meat", + "me time", + "the Underground Railroad", + "poorly-timed Holocaust jokes", + "a sea of troubles", + "lumberjack fantasies", + "Morgan Freeman’s voice", + "women in yogurt commercials", + "natural male enhancement", + "being a motherfucking sorcerer", + "my black ass", + "genuine human connection", + "announcing that I am about to cum", + "balls", + "Grandma", + "friction", + "chunks of dead hitchhiker", + "farting and walking away", + "being a dick to children", + "one trillion dollars", + "drowning the kids in the bathtub", + "dying", + "drinking out of the toilet and eating garbage", + "the gays", + "the screams… the terrible screams", + "men", + "the bombing of Nagasaki", + "fake tits", + "the Amish", + "David Bowie flying in on a tiger made of lightning", + "my ugly face and bad personality", + "a bitch slap", + "a brain tumor", + "fear itself", + "Jews, gypsies, and homosexuals", + "the milkman", + "Cards Against Humanity" + ] +} diff --git a/server/src/ts/action/privileged/configure.ts b/server/src/ts/action/privileged/configure.ts index cc9d654e..1883a4d8 100644 --- a/server/src/ts/action/privileged/configure.ts +++ b/server/src/ts/action/privileged/configure.ts @@ -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 = ( 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, diff --git a/server/src/ts/action/validation.validator.ts b/server/src/ts/action/validation.validator.ts index 64e4a8d9..6e6416b4 100644 --- a/server/src/ts/action/validation.validator.ts +++ b/server/src/ts/action/validation.validator.ts @@ -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.", diff --git a/server/src/ts/cache.ts b/server/src/ts/cache.ts index 0919bc9c..079c8031 100644 --- a/server/src/ts/cache.ts +++ b/server/src/ts/cache.ts @@ -40,13 +40,16 @@ export abstract class Cache { */ public abstract readonly config: ServerConfig.Cache; - private async get( - source: Source.Resolver, - getCachedAlways: ( - source: Source.Resolver - ) => Promise | 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, + Result + >( + source: Resolver, + getCachedAlways: (source: Resolver) => Promise | undefined>, + cacheAlways: (source: Resolver, value: Always) => void, + cacheSometimes: (source: Resolver, value: Sometimes) => void, extract: (result: Result) => [Always, Sometimes | undefined], miss: () => Promise ): Promise { @@ -64,7 +67,7 @@ export abstract class Cache { } private async cacheExpired( - source: Source.Resolver, + source: Source.Resolver, cached: Aged ): Promise { if ( @@ -85,7 +88,7 @@ export abstract class Cache { * to do so. */ public async getSummary( - source: Source.Resolver, + source: Source.Resolver, miss: () => Promise ): Promise { 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, miss: () => Promise ): Promise { 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 ): Promise | undefined>; /** * Store the given summary in the cache. */ public abstract async cacheSummary( - source: Source.Resolver, + source: Source.Resolver, summary: Source.Summary ): Promise; public cacheSummaryBackground( - source: Source.Resolver, + source: Source.Resolver, 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 ): Promise | undefined>; /** * Store the given deck templates in the cache. */ public abstract async cacheTemplates( - source: Source.Resolver, + source: Source.Resolver, templates: Decks.Templates ): Promise; public cacheTemplatesBackground( - source: Source.Resolver, + source: Source.Resolver, templates: Decks.Templates ): void { this.cacheTemplates(source, templates).catch(Cache.logError); diff --git a/server/src/ts/caches/in-memory.ts b/server/src/ts/caches/in-memory.ts index 883fd3be..24a593c5 100644 --- a/server/src/ts/caches/in-memory.ts +++ b/server/src/ts/caches/in-memory.ts @@ -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 + ): [string, string] { return [source.id(), source.deckId()]; } public async cacheSummary( - source: Source.Resolver, + source: Source.Resolver, summary: Source.Summary ): Promise { this.cache.summaries.set(InMemoryCache.key(source), { cached: summary, - age: Date.now() + age: Date.now(), }); } public async cacheTemplates( - source: Source.Resolver, + source: Source.Resolver, templates: Decks.Templates ): Promise { this.cache.templates.set(InMemoryCache.key(source), { cached: templates, - age: Date.now() + age: Date.now(), }); } public async getCachedSummary( - source: Source.Resolver + source: Source.Resolver ): Promise | undefined> { return this.cache.summaries.get(InMemoryCache.key(source)); } public async getCachedTemplates( - source: Source.Resolver + source: Source.Resolver ): Promise | undefined> { return this.cache.templates.get(InMemoryCache.key(source)); } diff --git a/server/src/ts/caches/postgres.ts b/server/src/ts/caches/postgres.ts index 60346924..82cdc57e 100644 --- a/server/src/ts/caches/postgres.ts +++ b/server/src/ts/caches/postgres.ts @@ -97,7 +97,7 @@ export class PostgresCache extends Cache.Cache { } public async cacheSummary( - source: Source.Resolver, + source: Source.Resolver, summary: Source.Summary ): Promise { 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, templates: Decks.Templates ): Promise { 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 ): Promise | 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 ): Promise | undefined> { return await this.pg.withClient(async (client) => { const about = await client.query( diff --git a/server/src/ts/config.ts b/server/src/ts/config.ts index 4790a565..3e5f48c7 100644 --- a/server/src/ts/config.ts +++ b/server/src/ts/config.ts @@ -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 extends EnvironmentalConfig { + sources: BaseSources; timeouts: Timeouts; tasks: Tasks; storage: BaseStorage; @@ -42,6 +43,23 @@ type Tasks = { processTickFrequency: D; }; +export interface BuiltIn { + basePath: string; + decks: string[]; +} + +interface BaseCardcast { + timeout: D; + simultaneousConnections: number; +} +export type Cardcast = BaseCardcast; + +interface BaseSources { + builtIn?: BuiltIn; + cardcast?: BaseCardcast; +} +export type Sources = BaseSources; + type BaseStorage = BaseInMemory | BasePostgreSQL; export type Storage = BaseStorage; @@ -100,14 +118,14 @@ export const parseStorage = ( ): BaseStorage => ({ ...storage, abandonedTime: parseDuration(storage.abandonedTime), - garbageCollectionFrequency: parseDuration(storage.garbageCollectionFrequency) + garbageCollectionFrequency: parseDuration(storage.garbageCollectionFrequency), }); export const parseCache = ( cache: BaseCache ): BaseCache => ({ ...cache, - checkAfter: parseDuration(cache.checkAfter) + checkAfter: parseDuration(cache.checkAfter), }); export const parseTimeouts = ( @@ -121,7 +139,19 @@ export const parseTasks = ( tasks: Tasks ): Tasks => ({ ...tasks, - processTickFrequency: parseDuration(tasks.processTickFrequency) + processTickFrequency: parseDuration(tasks.processTickFrequency), +}); + +const parseCardcast = (cardcast: BaseCardcast): Cardcast => ({ + ...cardcast, + timeout: parseDuration(cardcast.timeout), +}); + +const parseSources = (sources: BaseSources): 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), }); diff --git a/server/src/ts/errors/action-execution-error.ts b/server/src/ts/errors/action-execution-error.ts index 7ab0ea88..8d5da033 100644 --- a/server/src/ts/errors/action-execution-error.ts +++ b/server/src/ts/errors/action-execution-error.ts @@ -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, }); } diff --git a/server/src/ts/games/cards/card.ts b/server/src/ts/games/cards/card.ts index 56fef25e..1def2cef 100644 --- a/server/src/ts/games/cards/card.ts +++ b/server/src/ts/games/cards/card.ts @@ -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. diff --git a/server/src/ts/games/cards/source.ts b/server/src/ts/games/cards/source.ts index 740b338e..9fa34ce4 100644 --- a/server/src/ts/games/cards/source.ts +++ b/server/src/ts/games/cards/source.ts @@ -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 { + 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 + implements LimitedResolver { /** * 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 extends Resolver { + private readonly resolver: Resolver; private readonly cache: Cache.Cache; - public constructor(cache: Cache.Cache, resolver: Resolver) { + public constructor(cache: Cache.Cache, resolver: Resolver) { 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 { + limitedResolver(source: S): LimitedResolver; + resolver(source: S): Resolver; +} diff --git a/server/src/ts/games/cards/sources.ts b/server/src/ts/games/cards/sources.ts index cb818bc9..0a84e374 100644 --- a/server/src/ts/games/cards/sources.ts +++ b/server/src/ts/games/cards/sources.ts @@ -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: Config | undefined, + load: (value: Config) => Promise +): Promise { + 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 interface ClientInfo { + builtIn?: BuiltIn.ClientInfo; + cardcast?: boolean; } -export class SourceServiceError extends Error { - public constructor() { - super("The given source was not available."); + +export class Sources { + public readonly builtIn?: BuiltIn.MetaResolver; + public readonly cardcast?: Cardcast.MetaResolver; + + 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 | undefined { + switch (source.source) { + case "BuiltIn": + return this.builtIn; + + case "Cardcast": + return this.cardcast; + + default: + Util.assertNever(source); + } } -} -/** - * Get the limited resolver for the given source. - */ -export const limitedResolver = ( - source: Source.External -): Source.LimitedResolver => uncachedResolver(source); - -/** - * 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 => { - switch (source.source) { - case "Custom": - return Player.details(source); - default: - return await resolver(cache, source).details(); - } -}; + private metaResolver( + source: Source.External + ): Source.MetaResolver { + 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 { + return this.metaResolver(source).resolver(source); + } + + /** + * Get the resolver for the given source. + */ + public resolver( + cache: Cache, + source: Source.External + ): Source.Resolver { + 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 => { + 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 { + 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); + } +} diff --git a/server/src/ts/games/cards/sources/builtIn.ts b/server/src/ts/games/cards/sources/builtIn.ts new file mode 100644 index 00000000..a084cc61 --- /dev/null +++ b/server/src/ts/games/cards/sources/builtIn.ts @@ -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 { + 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 { + return undefined; + } + + public async atLeastSummary(): Promise { + if (this.storedSummary === undefined) { + throw new SourceNotFoundError(this.source); + } + return { + summary: this.storedSummary, + }; + } + + public async atLeastTemplates(): Promise { + 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 { + private readonly config: Config.BuiltIn; + private readonly summaries: Map; + + public constructor( + config: Config.BuiltIn, + summaries: Map + ) { + this.config = config; + this.summaries = summaries; + } + + limitedResolver(source: BuiltIn): Source.LimitedResolver { + 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 { + const summaries = new Map(); + + 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); +} diff --git a/server/src/ts/games/cards/sources/cardcast.ts b/server/src/ts/games/cards/sources/cardcast.ts index 10b5620d..04522e0b 100644 --- a/server/src/ts/games/cards/sources/cardcast.ts +++ b/server/src/ts/games/cards/sources/cardcast.ts @@ -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 { - 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 { 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 { public readonly source: Cardcast; + private readonly connectionPool: genericPool.Pool; - public constructor(source: Cardcast) { + public constructor( + source: Cardcast, + connectionPool: genericPool.Pool + ) { 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 { - const summary = await Resolver.get( - summaryUrl(this.source.playCode) - ); + const summary = await this.get(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 { - const deck = await Resolver.get(deckUrl(this.source.playCode)); + const deck = await this.get(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(url: string): Promise { - const connection = await connectionPool.acquire(); + private async get(url: string): Promise { + 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 { + /** + * We pool requests to cardcast to stop us hitting them too hard (on top of caching). + */ + private readonly connectionPool: genericPool.Pool; + + 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 => + new MetaResolver(config); diff --git a/server/src/ts/index.ts b/server/src/ts/index.ts index cd8fc14e..79b90344 100644 --- a/server/src/ts/index.ts +++ b/server/src/ts/index.ts @@ -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 { 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 { app.use( expressWinston.logger({ - winstonInstance: Logging.logger + winstonInstance: Logging.logger, }) ); @@ -123,12 +122,12 @@ async function main(): Promise { 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 { 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 { 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 { app.use( expressWinston.errorLogger({ winstonInstance: Logging.logger, - msg: "{{err.message}}" + msg: "{{err.message}}", }) ); @@ -237,18 +240,20 @@ async function main(): Promise { 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); }); diff --git a/server/src/ts/server-state.ts b/server/src/ts/server-state.ts index f74e1476..801e5cf2 100644 --- a/server/src/ts/server-state.ts +++ b/server/src/ts/server-state.ts @@ -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 { + 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, }; } diff --git a/server/src/ts/task/load-deck-summary.ts b/server/src/ts/task/load-deck-summary.ts index 92b9597d..ab4ef545 100644 --- a/server/src/ts/task/load-deck-summary.ts +++ b/server/src/ts/task/load-deck-summary.ts @@ -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 { private readonly source: Source.External; @@ -23,7 +22,8 @@ export class LoadDeckSummary extends Task.TaskBase { } protected async begin(server: ServerState): Promise { - 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 { 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 { } 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( diff --git a/server/src/ts/task/start-game.ts b/server/src/ts/task/start-game.ts index 29db9898..a76630c6 100644 --- a/server/src/ts/task/start-game.ts +++ b/server/src/ts/task/start-game.ts @@ -19,8 +19,8 @@ export class StartGame extends Task.TaskBase { protected async begin(server: ServerState): Promise { 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 { return { lobby, events: atStartOfRound.events, - timeouts: atStartOfRound.timeouts + timeouts: atStartOfRound.timeouts, }; }