summaryrefslogtreecommitdiff
path: root/sw-ui/src/main/kotlin/org
diff options
context:
space:
mode:
authorJoffrey Bion <joffrey.bion@booking.com>2020-04-06 18:55:25 +0200
committerJoffrey Bion <joffrey.bion@booking.com>2020-04-06 18:55:58 +0200
commitd4d20533556928f63c8759437f67e76336bab55e (patch)
tree34e7bb151b5d21497665131b6ab8d875254e7666 /sw-ui/src/main/kotlin/org
parentRefactoring in GameScene.kt (diff)
downloadseven-wonders-d4d20533556928f63c8759437f67e76336bab55e.tar.gz
seven-wonders-d4d20533556928f63c8759437f67e76336bab55e.tar.bz2
seven-wonders-d4d20533556928f63c8759437f67e76336bab55e.zip
Delete old React/TypeScript UI
Diffstat (limited to 'sw-ui/src/main/kotlin/org')
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt49
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt22
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt36
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt124
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt43
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt178
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt174
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt164
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt76
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt10
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt133
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt36
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt64
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt21
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt19
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt66
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt121
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt57
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt106
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt26
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt23
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt84
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt29
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt39
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt50
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt36
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt42
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt54
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt137
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt32
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt15
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt16
32 files changed, 2082 insertions, 0 deletions
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt
new file mode 100644
index 00000000..8b38e010
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt
@@ -0,0 +1,49 @@
+package org.luxons.sevenwonders.ui
+
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import org.luxons.sevenwonders.ui.components.application
+import org.luxons.sevenwonders.ui.redux.SwState
+import org.luxons.sevenwonders.ui.redux.configureStore
+import org.luxons.sevenwonders.ui.redux.sagas.SagaManager
+import org.luxons.sevenwonders.ui.redux.sagas.rootSaga
+import org.w3c.dom.Element
+import react.dom.*
+import react.redux.provider
+import redux.RAction
+import redux.Store
+import redux.WrapperAction
+import kotlin.browser.document
+import kotlin.browser.window
+
+fun main() {
+ window.onload = {
+ val rootElement = document.getElementById("root")
+ if (rootElement != null) {
+ initializeAndRender(rootElement)
+ } else {
+ console.error("Element with ID 'root' was not found, cannot bootstrap react app")
+ }
+ }
+}
+
+private fun initializeAndRender(rootElement: Element) {
+ val store = initRedux()
+
+ render(rootElement) {
+ provider(store) {
+ application()
+ }
+ }
+}
+
+private fun initRedux(): Store<SwState, RAction, WrapperAction> {
+ val sagaManager = SagaManager<SwState, RAction, WrapperAction>()
+ val store = configureStore(sagaManager = sagaManager)
+ GlobalScope.launch {
+ sagaManager.launchSaga(this) {
+ rootSaga()
+ }
+ }
+ return store
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt
new file mode 100644
index 00000000..b1244b5c
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt
@@ -0,0 +1,22 @@
+package org.luxons.sevenwonders.ui.components
+
+import org.luxons.sevenwonders.ui.components.game.gameScene
+import org.luxons.sevenwonders.ui.components.gameBrowser.gameBrowser
+import org.luxons.sevenwonders.ui.components.home.home
+import org.luxons.sevenwonders.ui.components.lobby.lobby
+import org.luxons.sevenwonders.ui.router.Route
+import react.RBuilder
+import react.router.dom.hashRouter
+import react.router.dom.redirect
+import react.router.dom.route
+import react.router.dom.switch
+
+fun RBuilder.application() = hashRouter {
+ switch {
+ route(Route.GAME_BROWSER.path) { gameBrowser() }
+ route(Route.GAME.path) { gameScene() }
+ route(Route.LOBBY.path) { lobby() }
+ route(Route.HOME.path, exact = true) { home() }
+ redirect(from = "*", to = "/")
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt
new file mode 100644
index 00000000..f5b16248
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt
@@ -0,0 +1,36 @@
+package org.luxons.sevenwonders.ui.components
+
+import kotlinx.css.Overflow
+import kotlinx.css.Position
+import kotlinx.css.bottom
+import kotlinx.css.left
+import kotlinx.css.overflow
+import kotlinx.css.pct
+import kotlinx.css.position
+import kotlinx.css.properties.transform
+import kotlinx.css.properties.translate
+import kotlinx.css.px
+import kotlinx.css.right
+import kotlinx.css.top
+import styled.StyleSheet
+
+object GlobalStyles : StyleSheet("GlobalStyles", isStatic = true) {
+
+ val fullscreen by css {
+ position = Position.fixed
+ top = 0.px
+ left = 0.px
+ bottom = 0.px
+ right = 0.px
+ overflow = Overflow.hidden
+ }
+
+ val fixedCenter by css {
+ position = Position.fixed
+ left = 50.pct
+ top = 50.pct
+ transform {
+ translate((-50).pct, (-50).pct)
+ }
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt
new file mode 100644
index 00000000..dd67757a
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt
@@ -0,0 +1,124 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import kotlinx.css.Color
+import kotlinx.css.Display
+import kotlinx.css.Position
+import kotlinx.css.TextAlign
+import kotlinx.css.display
+import kotlinx.css.height
+import kotlinx.css.margin
+import kotlinx.css.maxHeight
+import kotlinx.css.maxWidth
+import kotlinx.css.pct
+import kotlinx.css.position
+import kotlinx.css.properties.boxShadow
+import kotlinx.css.properties.transform
+import kotlinx.css.properties.translate
+import kotlinx.css.rem
+import kotlinx.css.textAlign
+import kotlinx.css.vh
+import kotlinx.css.vw
+import kotlinx.css.width
+import kotlinx.css.zIndex
+import kotlinx.html.DIV
+import kotlinx.html.title
+import org.luxons.sevenwonders.model.boards.Board
+import org.luxons.sevenwonders.model.cards.TableCard
+import org.luxons.sevenwonders.model.wonders.ApiWonder
+import react.RBuilder
+import styled.StyledDOMBuilder
+import styled.css
+import styled.styledDiv
+import styled.styledImg
+
+// card offsets in % of their size when displayed in columns
+private const val xOffset = 20
+private const val yOffset = 21
+
+fun RBuilder.boardComponent(board: Board) {
+ styledDiv {
+ css {
+ width = 100.vw
+ }
+ tableCards(cardColumns = board.playedCards)
+ wonderComponent(wonder = board.wonder)
+ }
+}
+
+private fun RBuilder.tableCards(cardColumns: List<List<TableCard>>) {
+ styledDiv {
+ css {
+ display = Display.flex
+ height = 40.vh
+ width = 100.vw
+ }
+ cardColumns.forEach { cards ->
+ tableCardColumn(cards = cards) {
+ attrs {
+ key = cards.first().color.toString()
+ }
+ }
+ }
+ }
+}
+
+private fun RBuilder.tableCardColumn(cards: List<TableCard>, block: StyledDOMBuilder<DIV>.() -> Unit = {}) {
+ styledDiv {
+ css {
+ height = 40.vh
+ width = 15.vw
+ margin = "auto"
+ position = Position.relative
+ }
+ block()
+ cards.forEachIndexed { index, card ->
+ tableCard(card = card, indexInColumn = index) {
+ attrs { key = card.name }
+ }
+ }
+ }
+}
+
+private fun RBuilder.tableCard(card: TableCard, indexInColumn: Int, block: StyledDOMBuilder<DIV>.() -> Unit = {}) {
+ styledDiv {
+ css {
+ position = Position.absolute
+ zIndex = indexInColumn
+ transform {
+ translate(
+ tx = (indexInColumn * xOffset).pct,
+ ty = (indexInColumn * yOffset).pct
+ )
+ }
+ }
+ block()
+ val highlightColor = if (card.playedDuringLastMove) Color.gold else null
+ cardImage(card = card, highlightColor = highlightColor) {
+ css {
+ maxWidth = 10.vw
+ maxHeight = 25.vh
+ }
+ }
+ }
+}
+
+private fun RBuilder.wonderComponent(wonder: ApiWonder) {
+ styledDiv {
+ css {
+ width = 100.vw
+ textAlign = TextAlign.center
+ }
+ styledImg(src="/images/wonders/${wonder.image}") {
+ css {
+ declarations["border-radius"] = "0.5%/1.5%"
+ boxShadow(color = Color.black, offsetX = 0.2.rem, offsetY = 0.2.rem, blurRadius = 0.5.rem)
+ maxHeight = 30.vh
+ maxWidth = 95.vw
+ }
+ attrs {
+ this.title = wonder.name
+ this.alt = "Wonder ${wonder.name}"
+ }
+ }
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt
new file mode 100644
index 00000000..38afe028
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt
@@ -0,0 +1,43 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import kotlinx.css.CSSBuilder
+import kotlinx.css.Color
+import kotlinx.css.borderRadius
+import kotlinx.css.pct
+import kotlinx.css.properties.boxShadow
+import kotlinx.css.px
+import kotlinx.css.rem
+import kotlinx.html.IMG
+import kotlinx.html.title
+import org.luxons.sevenwonders.model.cards.Card
+import react.RBuilder
+import styled.StyledDOMBuilder
+import styled.css
+import styled.styledImg
+
+fun RBuilder.cardImage(card: Card, highlightColor: Color? = null, block: StyledDOMBuilder<IMG>.() -> Unit = {}) {
+ styledImg(src = "/images/cards/${card.image}") {
+ css {
+ borderRadius = 5.pct
+ boxShadow(offsetX = 2.px, offsetY = 2.px, blurRadius = 5.px, color = Color.black)
+ highlightStyle(highlightColor)
+ }
+ attrs {
+ title = card.name
+ alt = "Card ${card.name}"
+ }
+ block()
+ }
+}
+
+private fun CSSBuilder.highlightStyle(highlightColor: Color?) {
+ if (highlightColor != null) {
+ boxShadow(
+ offsetX = 0.px,
+ offsetY = 0.px,
+ blurRadius = 1.rem,
+ spreadRadius = 0.1.rem,
+ color = highlightColor
+ )
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt
new file mode 100644
index 00000000..d54a0240
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt
@@ -0,0 +1,178 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpButton
+import com.palantir.blueprintjs.bpButtonGroup
+import com.palantir.blueprintjs.bpOverlay
+import kotlinx.css.Position
+import kotlinx.css.background
+import kotlinx.css.backgroundSize
+import kotlinx.css.bottom
+import kotlinx.css.left
+import kotlinx.css.pct
+import kotlinx.css.position
+import kotlinx.css.properties.transform
+import kotlinx.css.properties.translate
+import kotlinx.css.px
+import kotlinx.css.rem
+import kotlinx.css.right
+import kotlinx.css.top
+import kotlinx.html.DIV
+import org.luxons.sevenwonders.model.Action
+import org.luxons.sevenwonders.model.PlayerMove
+import org.luxons.sevenwonders.model.PlayerTurnInfo
+import org.luxons.sevenwonders.model.api.PlayerDTO
+import org.luxons.sevenwonders.model.cards.HandCard
+import org.luxons.sevenwonders.ui.components.GlobalStyles
+import org.luxons.sevenwonders.ui.redux.GameState
+import org.luxons.sevenwonders.ui.redux.RequestPrepareMove
+import org.luxons.sevenwonders.ui.redux.RequestSayReady
+import org.luxons.sevenwonders.ui.redux.RequestUnprepareMove
+import org.luxons.sevenwonders.ui.redux.connectStateAndDispatch
+import react.RBuilder
+import react.RClass
+import react.RComponent
+import react.RProps
+import react.RState
+import react.ReactElement
+import react.dom.*
+import styled.StyledDOMBuilder
+import styled.css
+import styled.styledDiv
+
+interface GameSceneStateProps: RProps {
+ var playerIsReady: Boolean
+ var players: List<PlayerDTO>
+ var gameState: GameState?
+ var preparedMove: PlayerMove?
+ var preparedCard: HandCard?
+}
+
+interface GameSceneDispatchProps: RProps {
+ var sayReady: () -> Unit
+ var prepareMove: (move: PlayerMove) -> Unit
+ var unprepareMove: () -> Unit
+}
+
+interface GameSceneProps : GameSceneStateProps, GameSceneDispatchProps
+
+private class GameScene(props: GameSceneProps) : RComponent<GameSceneProps, RState>(props) {
+
+ override fun RBuilder.render() {
+ styledDiv {
+ css {
+ background = "url('images/background-papyrus3.jpg')"
+ backgroundSize = "cover"
+ +GlobalStyles.fullscreen
+ }
+ val turnInfo = props.gameState?.turnInfo
+ if (turnInfo == null) {
+ p { +"Error: no turn info data"}
+ } else {
+ turnInfoScene(turnInfo)
+ }
+ }
+ }
+
+ private fun RBuilder.sayReadyButton(): ReactElement {
+ val isReady = props.playerIsReady
+ val intent = if (isReady) Intent.SUCCESS else Intent.PRIMARY
+ return styledDiv {
+ css {
+ position = Position.absolute
+ bottom = 6.rem
+ left = 50.pct
+ transform { translate(tx = (-50).pct) }
+ }
+ bpButtonGroup {
+ bpButton(
+ large = true,
+ disabled = isReady,
+ intent = intent,
+ icon = if (isReady) "tick-circle" else "play",
+ onClick = { props.sayReady() }
+ ) {
+ +"READY"
+ }
+ // not really a button, but nice for style
+ bpButton(
+ large = true,
+ icon = "people",
+ disabled = isReady,
+ intent = intent
+ ) {
+ +"${props.players.count { it.isReady }}/${props.players.size}"
+ }
+ }
+ }
+ }
+
+ private fun RBuilder.turnInfoScene(turnInfo: PlayerTurnInfo) {
+ val board = turnInfo.table.boards[turnInfo.playerIndex]
+ div {
+ // TODO use blueprint's Callout component without header and primary intent
+ p { + turnInfo.message }
+ boardComponent(board = board)
+ val hand = turnInfo.hand
+ if (hand != null) {
+ handComponent(
+ cards = hand,
+ wonderUpgradable = turnInfo.wonderBuildability.isBuildable,
+ preparedMove = props.preparedMove,
+ prepareMove = props.prepareMove
+ )
+ }
+ val card = props.preparedCard
+ if (card != null) {
+ preparedMove(card)
+ }
+ if (turnInfo.action == Action.SAY_READY) {
+ sayReadyButton()
+ }
+ productionBar(gold = board.gold, production = board.production)
+ }
+ }
+
+ private fun RBuilder.preparedMove(card: HandCard) {
+ bpOverlay(isOpen = true, onClose = props.unprepareMove) {
+ styledDiv {
+ css { +GlobalStyles.fixedCenter }
+ cardImage(card)
+ styledDiv {
+ css {
+ position = Position.absolute
+ top = 0.px
+ right = 0.px
+ }
+ bpButton(
+ icon = "cross",
+ title = "Cancel prepared move",
+ small = true,
+ intent = Intent.DANGER,
+ onClick = { props.unprepareMove() }
+ )
+ }
+ }
+ }
+ }
+}
+
+fun RBuilder.gameScene() = gameScene {}
+
+private val gameScene: RClass<GameSceneProps> = connectStateAndDispatch<GameSceneStateProps, GameSceneDispatchProps,
+ GameSceneProps>(
+ clazz = GameScene::class,
+ mapDispatchToProps = { dispatch, _ ->
+ prepareMove = { move -> dispatch(RequestPrepareMove(move)) }
+ unprepareMove = { dispatch(RequestUnprepareMove()) }
+ sayReady = { dispatch(RequestSayReady()) }
+ },
+ mapStateToProps = { state, _ ->
+ playerIsReady = state.currentPlayer?.isReady == true
+ players = state.gameState?.players ?: emptyList()
+ gameState = state.gameState
+ preparedMove = state.gameState?.currentPreparedMove
+ preparedCard = state.gameState?.currentPreparedCard
+ }
+)
+
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt
new file mode 100644
index 00000000..17ceffd2
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt
@@ -0,0 +1,174 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpButton
+import com.palantir.blueprintjs.bpButtonGroup
+import kotlinx.css.Align
+import kotlinx.css.CSSBuilder
+import kotlinx.css.Color
+import kotlinx.css.Display
+import kotlinx.css.GridColumn
+import kotlinx.css.GridRow
+import kotlinx.css.Position
+import kotlinx.css.alignItems
+import kotlinx.css.bottom
+import kotlinx.css.display
+import kotlinx.css.filter
+import kotlinx.css.gridColumn
+import kotlinx.css.gridRow
+import kotlinx.css.height
+import kotlinx.css.left
+import kotlinx.css.margin
+import kotlinx.css.maxHeight
+import kotlinx.css.maxWidth
+import kotlinx.css.pct
+import kotlinx.css.position
+import kotlinx.css.properties.boxShadow
+import kotlinx.css.properties.s
+import kotlinx.css.properties.transform
+import kotlinx.css.properties.transition
+import kotlinx.css.properties.translate
+import kotlinx.css.px
+import kotlinx.css.rem
+import kotlinx.css.vh
+import kotlinx.css.vw
+import kotlinx.css.width
+import kotlinx.css.zIndex
+import kotlinx.html.DIV
+import org.luxons.sevenwonders.model.MoveType
+import org.luxons.sevenwonders.model.PlayerMove
+import org.luxons.sevenwonders.model.cards.HandCard
+import org.luxons.sevenwonders.model.cards.PreparedCard
+import org.luxons.sevenwonders.ui.components.game.cardImage
+import react.RBuilder
+import styled.StyledDOMBuilder
+import styled.css
+import styled.styledDiv
+
+fun RBuilder.handComponent(
+ cards: List<HandCard>,
+ wonderUpgradable: Boolean,
+ preparedMove: PlayerMove?,
+ prepareMove: (PlayerMove) -> Unit
+) {
+ styledDiv {
+ css {
+ handStyle()
+ }
+ cards.filter { it.name != preparedMove?.cardName }.forEachIndexed { index, c ->
+ handCard(
+ card = c,
+ wonderUpgradable = wonderUpgradable,
+ prepareMove = prepareMove
+ ) {
+ attrs {
+ key = index.toString()
+ }
+ }
+ }
+ }
+}
+
+private fun RBuilder.handCard(
+ card: HandCard,
+ wonderUpgradable: Boolean,
+ prepareMove: (PlayerMove) -> Unit,
+ block: StyledDOMBuilder<DIV>.() -> Unit
+) {
+ styledDiv {
+ css {
+ handCardStyle()
+ }
+ block()
+ cardImage(card) {
+ css {
+ handCardImgStyle(card.playability.isPlayable)
+ }
+ }
+ actionButtons(card, wonderUpgradable, prepareMove)
+ }
+}
+
+private fun RBuilder.actionButtons(card: HandCard, wonderUpgradable: Boolean, prepareMove: (PlayerMove) -> Unit) {
+ // class: action-buttons
+ styledDiv {
+ css {
+ alignItems = Align.flexEnd
+ display = Display.none
+ gridRow = GridRow("1")
+ gridColumn = GridColumn("1")
+
+ ancestorHover(".hand-card") {
+ display = Display.flex
+ }
+ }
+ bpButtonGroup {
+ bpButton(title = "PLAY",
+ large = true,
+ intent = Intent.SUCCESS,
+ icon = "play",
+ disabled = !card.playability.isPlayable,
+ onClick = { prepareMove(PlayerMove(MoveType.PLAY, card.name)) })
+ bpButton(title = "UPGRADE WONDER",
+ large = true,
+ intent = Intent.PRIMARY,
+ icon = "key-shift",
+ disabled = !wonderUpgradable,
+ onClick = { prepareMove(PlayerMove(MoveType.UPGRADE_WONDER, card.name)) })
+ bpButton(title = "DISCARD",
+ large = true,
+ intent = Intent.DANGER,
+ icon = "cross",
+ onClick = { prepareMove(PlayerMove(MoveType.DISCARD, card.name)) })
+ }
+ }
+}
+
+private fun CSSBuilder.handStyle() {
+ alignItems = Align.center
+ bottom = 0.px
+ display = Display.flex
+ height = 345.px
+ left = 50.pct
+ maxHeight = 25.vw
+ position = Position.absolute
+ transform {
+ translate(tx = (-50).pct, ty = 55.pct)
+ }
+ transition(duration = 0.5.s)
+ zIndex = 30
+
+ hover {
+ bottom = 4.rem
+ transform {
+ translate(tx = (-50).pct, ty = 0.pct)
+ }
+ }
+}
+
+private fun CSSBuilder.handCardStyle() {
+ classes.add("hand-card")
+ alignItems = Align.flexEnd
+ display = Display.grid
+ margin(all = 0.2.rem)
+}
+
+private fun CSSBuilder.handCardImgStyle(isPlayable: Boolean) {
+ gridRow = GridRow("1")
+ gridColumn = GridColumn("1")
+ maxWidth = 13.vw
+ maxHeight = 60.vh
+ transition(duration = 0.1.s)
+ width = 11.rem
+
+ ancestorHover(".hand-card") {
+ boxShadow(offsetX = 0.px, offsetY = 10.px, blurRadius = 40.px, color = Color.black)
+ width = 14.rem
+ maxWidth = 15.vw
+ maxHeight = 90.vh
+ }
+
+ if (!isPlayable) {
+ filter = "grayscale(50%) contrast(50%)"
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt
new file mode 100644
index 00000000..773e9835
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt
@@ -0,0 +1,164 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import kotlinx.css.Align
+import kotlinx.css.BorderStyle
+import kotlinx.css.CSSBuilder
+import kotlinx.css.Color
+import kotlinx.css.Display
+import kotlinx.css.Position
+import kotlinx.css.VerticalAlign
+import kotlinx.css.alignItems
+import kotlinx.css.background
+import kotlinx.css.bottom
+import kotlinx.css.color
+import kotlinx.css.display
+import kotlinx.css.fontFamily
+import kotlinx.css.fontSize
+import kotlinx.css.height
+import kotlinx.css.margin
+import kotlinx.css.marginLeft
+import kotlinx.css.position
+import kotlinx.css.properties.borderTop
+import kotlinx.css.properties.boxShadow
+import kotlinx.css.px
+import kotlinx.css.rem
+import kotlinx.css.verticalAlign
+import kotlinx.css.vw
+import kotlinx.css.width
+import kotlinx.css.zIndex
+import kotlinx.html.DIV
+import kotlinx.html.title
+import org.luxons.sevenwonders.model.boards.Production
+import org.luxons.sevenwonders.model.resources.CountedResource
+import org.luxons.sevenwonders.model.resources.ResourceType
+import react.RBuilder
+import react.dom.*
+import styled.StyledDOMBuilder
+import styled.css
+import styled.styledDiv
+import styled.styledImg
+import styled.styledSpan
+
+fun RBuilder.productionBar(gold: Int, production: Production) {
+ styledDiv {
+ css {
+ productionBarStyle()
+ }
+ goldIndicator(gold)
+ fixedResources(production.fixedResources)
+ alternativeResources(production.alternativeResources)
+ }
+}
+
+private fun RBuilder.goldIndicator(amount: Int) {
+ tokenWithCount(tokenName = "coin", count = amount)
+}
+
+private fun RBuilder.fixedResources(resources: List<CountedResource>) {
+ styledDiv {
+ css {
+ margin = "auto"
+ display = Display.flex
+ }
+ resources.forEach {
+ tokenWithCount(tokenName = getTokenName(it.type), count = it.count) {
+ attrs { key = it.type.toString() }
+ css { marginLeft = 1.rem }
+ }
+ }
+ }
+}
+
+private fun RBuilder.alternativeResources(resources: Set<Set<ResourceType>>) {
+ styledDiv {
+ css {
+ margin = "auto"
+ display = Display.flex
+ }
+ resources.forEachIndexed { index, res ->
+ resourceChoice(types = res) {
+ attrs {
+ key = index.toString()
+ }
+ }
+ }
+ }
+}
+
+private fun RBuilder.resourceChoice(types: Set<ResourceType>, block: StyledDOMBuilder<DIV>.() -> Unit = {}) {
+ styledDiv {
+ css {
+ marginLeft = (1.5).rem
+ }
+ block()
+ for ((i, t) in types.withIndex()) {
+ tokenImage(tokenName = getTokenName(t)) {
+ attrs { this.key = t.toString() }
+ }
+ if (i < types.indices.last) {
+ styledSpan { css { choiceSeparatorStyle() } }
+ }
+ }
+ }
+}
+
+private fun RBuilder.tokenWithCount(tokenName: String, count: Int, block: StyledDOMBuilder<DIV>.() -> Unit = {}) {
+ styledDiv {
+ block()
+ tokenImage(tokenName)
+ styledSpan {
+ css { tokenCountStyle() }
+ + "× $count"
+ }
+ }
+}
+
+private fun RBuilder.tokenImage(tokenName: String, block: StyledDOMBuilder<DIV>.() -> Unit = {}) {
+ styledImg(src = getTokenImagePath(tokenName)) {
+ css {
+ tokenImageStyle()
+ }
+ attrs {
+ this.title = tokenName
+ this.alt = tokenName
+ }
+ }
+}
+
+private fun getTokenImagePath(tokenName: String)= "/images/tokens/${tokenName}.png"
+
+private fun getTokenName(resourceType: ResourceType)= "resources/${resourceType.toString().toLowerCase()}"
+
+private fun CSSBuilder.productionBarStyle() {
+ alignItems = Align.center
+ background = "linear-gradient(#eaeaea, #888 7%)"
+ bottom = 0.px
+ borderTop(width = 1.px, color = Color("#8b8b8b"), style = BorderStyle.solid)
+ boxShadow(blurRadius = 15.px, color = Color("#747474"))
+ display = Display.flex
+ height = (3.5).rem
+ width = 100.vw
+ position = Position.fixed
+ zIndex = 99
+}
+
+private fun CSSBuilder.choiceSeparatorStyle() {
+ fontSize = 2.rem
+ verticalAlign = VerticalAlign.middle
+ margin(all = 5.px)
+ color = Color("#c29929")
+ declarations["text-shadow"] = "0 0 1px black"
+}
+
+private fun CSSBuilder.tokenImageStyle() {
+ height = 3.rem
+ width = 3.rem
+ verticalAlign = VerticalAlign.middle
+}
+
+private fun CSSBuilder.tokenCountStyle() {
+ fontFamily = "fantasy"
+ fontSize = 1.5.rem
+ verticalAlign = VerticalAlign.middle
+ marginLeft = 0.2.rem
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt
new file mode 100644
index 00000000..876a167e
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt
@@ -0,0 +1,76 @@
+package org.luxons.sevenwonders.ui.components.gameBrowser
+
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpButton
+import com.palantir.blueprintjs.bpInputGroup
+import kotlinx.css.Display
+import kotlinx.css.FlexDirection
+import kotlinx.css.JustifyContent
+import kotlinx.css.display
+import kotlinx.css.flexDirection
+import kotlinx.css.justifyContent
+import kotlinx.html.js.onSubmitFunction
+import org.luxons.sevenwonders.ui.redux.RequestCreateGame
+import org.luxons.sevenwonders.ui.redux.connectDispatch
+import org.luxons.sevenwonders.ui.utils.createElement
+import org.w3c.dom.HTMLInputElement
+import org.w3c.dom.events.Event
+import react.RBuilder
+import react.RClass
+import react.RComponent
+import react.RProps
+import react.RState
+import react.dom.*
+import styled.css
+import styled.styledDiv
+
+private interface CreateGameFormProps: RProps {
+ var createGame: (String) -> Unit
+}
+
+private data class CreateGameFormState(var gameName: String = ""): RState
+
+private class CreateGameForm(props: CreateGameFormProps): RComponent<CreateGameFormProps, CreateGameFormState>(props) {
+
+ override fun CreateGameFormState.init(props: CreateGameFormProps) {
+ gameName = ""
+ }
+
+ override fun RBuilder.render() {
+ styledDiv {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.row
+ justifyContent = JustifyContent.spaceBetween
+ }
+ form {
+ attrs {
+ onSubmitFunction = { e -> createGame(e) }
+ }
+
+ bpInputGroup(
+ placeholder = "Game name",
+ onChange = { e ->
+ val input = e.currentTarget as HTMLInputElement
+ setState(transformState = { CreateGameFormState(input.value) })
+ },
+ rightElement = createGameButton()
+ )
+ }
+ playerInfo()
+ }
+ }
+
+ private fun createGameButton() = createElement {
+ bpButton(minimal = true, intent = Intent.PRIMARY, icon = "add", onClick = { e -> createGame(e) })
+ }
+
+ private fun createGame(e: Event) {
+ e.preventDefault() // prevents refreshing the page when pressing Enter
+ props.createGame(state.gameName)
+ }
+}
+
+val createGameForm: RClass<RProps> = connectDispatch(CreateGameForm::class) { dispatch, _ ->
+ createGame = { name -> dispatch(RequestCreateGame(name)) }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt
new file mode 100644
index 00000000..2f860ca7
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt
@@ -0,0 +1,10 @@
+package org.luxons.sevenwonders.ui.components.gameBrowser
+
+import react.RBuilder
+import react.dom.*
+
+fun RBuilder.gameBrowser() = div {
+ h1 { +"Games" }
+ createGameForm {}
+ gameList()
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt
new file mode 100644
index 00000000..47c17da1
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt
@@ -0,0 +1,133 @@
+package org.luxons.sevenwonders.ui.components.gameBrowser
+
+import com.palantir.blueprintjs.Classes
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpButton
+import com.palantir.blueprintjs.bpIcon
+import com.palantir.blueprintjs.bpTag
+import kotlinx.css.Align
+import kotlinx.css.Display
+import kotlinx.css.FlexDirection
+import kotlinx.css.VerticalAlign
+import kotlinx.css.alignItems
+import kotlinx.css.display
+import kotlinx.css.flexDirection
+import kotlinx.css.verticalAlign
+import kotlinx.html.classes
+import kotlinx.html.title
+import org.luxons.sevenwonders.model.api.ConnectedPlayer
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.model.api.State
+import org.luxons.sevenwonders.ui.redux.RequestJoinGame
+import org.luxons.sevenwonders.ui.redux.connectStateAndDispatch
+import react.RBuilder
+import react.RComponent
+import react.RProps
+import react.RState
+import react.dom.*
+import styled.css
+import styled.styledDiv
+import styled.styledSpan
+import styled.styledTr
+
+interface GameListStateProps : RProps {
+ var connectedPlayer: ConnectedPlayer
+ var games: List<LobbyDTO>
+}
+
+interface GameListDispatchProps: RProps {
+ var joinGame: (Long) -> Unit
+}
+
+interface GameListProps : GameListStateProps, GameListDispatchProps
+
+class GameListPresenter(props: GameListProps) : RComponent<GameListProps, RState>(props) {
+
+ override fun RBuilder.render() {
+ table {
+ attrs {
+ classes = setOf(Classes.HTML_TABLE)
+ }
+ thead {
+ gameListHeaderRow()
+ }
+ tbody {
+ props.games.forEach {
+ gameListItemRow(it, props.joinGame)
+ }
+ }
+ }
+ }
+
+ private fun RBuilder.gameListHeaderRow() = tr {
+ th { +"Name" }
+ th { +"Status" }
+ th { +"Nb Players" }
+ th { +"Join" }
+ }
+
+ private fun RBuilder.gameListItemRow(lobby: LobbyDTO, joinGame: (Long) -> Unit) = styledTr {
+ css {
+ verticalAlign = VerticalAlign.middle
+ }
+ attrs {
+ key = lobby.id.toString()
+ }
+ td { +lobby.name }
+ td { gameStatus(lobby.state) }
+ td { playerCount(lobby.players.size) }
+ td { joinButton(lobby) }
+ }
+
+ private fun RBuilder.gameStatus(state: State) {
+ val intent = when(state) {
+ State.LOBBY -> Intent.SUCCESS
+ State.PLAYING -> Intent.WARNING
+ State.FINISHED -> Intent.DANGER
+ }
+ bpTag(minimal = true, intent = intent) {
+ +state.toString()
+ }
+ }
+
+ private fun RBuilder.playerCount(nPlayers: Int) {
+ styledDiv {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.row
+ alignItems = Align.center
+ }
+ attrs {
+ title = "Number of players"
+ }
+ bpIcon(name = "people", title = null)
+ styledSpan {
+ +nPlayers.toString()
+ }
+ }
+ }
+
+ private fun RBuilder.joinButton(lobby: LobbyDTO) {
+ val joinability = lobby.joinability(props.connectedPlayer.displayName)
+ bpButton(
+ minimal = true,
+ title = joinability.tooltip,
+ icon = "arrow-right",
+ disabled = !joinability.canDo,
+ onClick = { props.joinGame(lobby.id) }
+ )
+ }
+}
+
+fun RBuilder.gameList() = gameList {}
+
+private val gameList = connectStateAndDispatch<GameListStateProps, GameListDispatchProps, GameListProps>(
+ clazz = GameListPresenter::class,
+ mapStateToProps = { state, _ ->
+ connectedPlayer = state.connectedPlayer ?: error("there should be a connected player")
+ games = state.games
+ },
+ mapDispatchToProps = { dispatch, _ ->
+ joinGame = { gameId -> dispatch(RequestJoinGame(gameId = gameId)) }
+ }
+)
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt
new file mode 100644
index 00000000..b939dfe1
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt
@@ -0,0 +1,36 @@
+package org.luxons.sevenwonders.ui.components.gameBrowser
+
+import org.luxons.sevenwonders.model.api.ConnectedPlayer
+import org.luxons.sevenwonders.ui.redux.connectState
+import react.RBuilder
+import react.RComponent
+import react.RProps
+import react.RState
+import react.dom.*
+
+interface PlayerInfoProps : RProps {
+ var connectedPlayer: ConnectedPlayer?
+}
+
+class PlayerInfoPresenter(props: PlayerInfoProps) : RComponent<PlayerInfoProps, RState>(props) {
+
+ override fun RBuilder.render() {
+ span {
+ b {
+ +"Username:"
+ }
+ props.connectedPlayer?.let {
+ + " ${it.displayName}"
+ }
+ }
+ }
+}
+
+fun RBuilder.playerInfo() = playerInfo {}
+
+private val playerInfo = connectState(
+ clazz = PlayerInfoPresenter::class,
+ mapStateToProps = { state, _ ->
+ connectedPlayer = state.connectedPlayer
+ }
+)
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt
new file mode 100644
index 00000000..1aa4be43
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt
@@ -0,0 +1,64 @@
+package org.luxons.sevenwonders.ui.components.home
+
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpButton
+import com.palantir.blueprintjs.bpInputGroup
+import kotlinx.html.js.onSubmitFunction
+import org.luxons.sevenwonders.ui.redux.RequestChooseName
+import org.luxons.sevenwonders.ui.redux.connectDispatch
+import org.luxons.sevenwonders.ui.utils.createElement
+import org.w3c.dom.HTMLInputElement
+import org.w3c.dom.events.Event
+import react.RBuilder
+import react.RClass
+import react.RComponent
+import react.RProps
+import react.RState
+import react.ReactElement
+import react.dom.*
+
+private interface ChooseNameFormProps: RProps {
+ var chooseUsername: (String) -> Unit
+}
+
+private data class ChooseNameFormState(var username: String = ""): RState
+
+private class ChooseNameForm(props: ChooseNameFormProps): RComponent<ChooseNameFormProps, ChooseNameFormState>(props) {
+
+ override fun ChooseNameFormState.init(props: ChooseNameFormProps) {
+ username = ""
+ }
+
+ override fun RBuilder.render() {
+ form {
+ attrs.onSubmitFunction = { e -> chooseUsername(e) }
+ bpInputGroup(
+ large = true,
+ placeholder = "Username",
+ rightElement = submitButton(),
+ onChange = { e ->
+ val input = e.currentTarget as HTMLInputElement
+ setState(transformState = { ChooseNameFormState(input.value) })
+ }
+ )
+ }
+ }
+
+ private fun submitButton(): ReactElement = createElement {
+ bpButton(
+ minimal = true,
+ icon = "arrow-right",
+ intent = Intent.PRIMARY,
+ onClick = { e -> chooseUsername(e) }
+ )
+ }
+
+ private fun chooseUsername(e: Event) {
+ e.preventDefault()
+ props.chooseUsername(state.username)
+ }
+}
+
+val chooseNameForm: RClass<RProps> = connectDispatch(ChooseNameForm::class) { dispatch, _ ->
+ chooseUsername = { name -> dispatch(RequestChooseName(name)) }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt
new file mode 100644
index 00000000..43a1592b
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt
@@ -0,0 +1,21 @@
+package org.luxons.sevenwonders.ui.components.home
+
+import org.luxons.sevenwonders.ui.components.GlobalStyles
+import react.RBuilder
+import react.dom.*
+import styled.css
+import styled.styledDiv
+
+private const val LOGO = "images/logo-7-wonders.png"
+
+fun RBuilder.home() = styledDiv {
+ css {
+ +GlobalStyles.fullscreen
+ +HomeStyles.centerChildren
+ +HomeStyles.zeusBackground
+ }
+
+ img(src = LOGO, alt = "Seven Wonders") {}
+
+ chooseNameForm {}
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt
new file mode 100644
index 00000000..624f430c
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt
@@ -0,0 +1,19 @@
+package org.luxons.sevenwonders.ui.components.home
+
+import kotlinx.css.*
+import styled.StyleSheet
+
+object HomeStyles : StyleSheet("HomeStyles", isStatic = true) {
+
+ val zeusBackground by css {
+ background = "url('images/background-zeus-temple.jpg') center no-repeat"
+ backgroundSize = "cover"
+ }
+
+ val centerChildren by css {
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = Align.center
+ justifyContent = JustifyContent.center
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt
new file mode 100644
index 00000000..5b13d8b1
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt
@@ -0,0 +1,66 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpButton
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.model.api.PlayerDTO
+import org.luxons.sevenwonders.ui.redux.RequestStartGame
+import org.luxons.sevenwonders.ui.redux.connectStateAndDispatch
+import react.RBuilder
+import react.RComponent
+import react.RProps
+import react.RState
+import react.dom.*
+
+interface LobbyStateProps : RProps {
+ var currentGame: LobbyDTO?
+ var currentPlayer: PlayerDTO?
+}
+
+interface LobbyDispatchProps : RProps {
+ var startGame: () -> Unit
+}
+
+interface LobbyProps : LobbyDispatchProps, LobbyStateProps
+
+class LobbyPresenter(props: LobbyProps) : RComponent<LobbyProps, RState>(props) {
+
+ override fun RBuilder.render() {
+ val currentGame = props.currentGame
+ val currentPlayer = props.currentPlayer
+ if (currentGame == null || currentPlayer == null) {
+ div { +"Error: no current game." }
+ return
+ }
+ div {
+ h2 { +"${currentGame.name} — Lobby" }
+ radialPlayerList(currentGame.players, currentPlayer)
+ if (currentPlayer.isGameOwner) {
+ val startability = currentGame.startability(currentPlayer.username)
+ bpButton(
+ large = true,
+ intent = Intent.PRIMARY,
+ icon = "play",
+ title = startability.tooltip,
+ disabled = !startability.canDo,
+ onClick = { props.startGame() }
+ ) {
+ + "START"
+ }
+ }
+ }
+ }
+}
+
+fun RBuilder.lobby() = lobby {}
+
+private val lobby = connectStateAndDispatch<LobbyStateProps, LobbyDispatchProps, LobbyProps>(
+ clazz = LobbyPresenter::class,
+ mapStateToProps = { state, _ ->
+ currentGame = state.currentLobby
+ currentPlayer = state.currentPlayer
+ },
+ mapDispatchToProps = { dispatch, _ ->
+ startGame = { dispatch(RequestStartGame()) }
+ }
+)
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt
new file mode 100644
index 00000000..be3bb1de
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt
@@ -0,0 +1,121 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import kotlinx.css.CSSBuilder
+import kotlinx.css.Display
+import kotlinx.css.ListStyleType
+import kotlinx.css.Position
+import kotlinx.css.display
+import kotlinx.css.height
+import kotlinx.css.left
+import kotlinx.css.listStyleType
+import kotlinx.css.margin
+import kotlinx.css.padding
+import kotlinx.css.pct
+import kotlinx.css.position
+import kotlinx.css.properties.Timing
+import kotlinx.css.properties.ms
+import kotlinx.css.properties.transform
+import kotlinx.css.properties.transition
+import kotlinx.css.properties.translate
+import kotlinx.css.px
+import kotlinx.css.top
+import kotlinx.css.width
+import kotlinx.css.zIndex
+import react.RBuilder
+import react.ReactElement
+import react.dom.*
+import styled.css
+import styled.styledDiv
+import styled.styledLi
+import styled.styledUl
+
+typealias ElementBuilder = RBuilder.() -> ReactElement
+
+fun RBuilder.radialList(
+ itemBuilders: List<ElementBuilder>,
+ centerElementBuilder: ElementBuilder,
+ itemWidth: Int,
+ itemHeight: Int,
+ options: RadialConfig = RadialConfig()
+): ReactElement {
+ val containerWidth = options.diameter + itemWidth
+ val containerHeight = options.diameter + itemHeight
+
+ return styledDiv {
+ css {
+ zeroMargins()
+ position = Position.relative
+ width = containerWidth.px
+ height = containerHeight.px
+ }
+ radialListItems(itemBuilders, options)
+ radialListCenter(centerElementBuilder)
+ }
+}
+
+private fun RBuilder.radialListItems(itemBuilders: List<ElementBuilder>, radialConfig: RadialConfig): ReactElement {
+ val offsets = offsetsFromCenter(itemBuilders.size, radialConfig)
+ return styledUl {
+ css {
+ zeroMargins()
+ transition(property = "all", duration = 500.ms, timing = Timing.easeInOut)
+ zIndex = 1
+ width = radialConfig.diameter.px
+ height = radialConfig.diameter.px
+ absoluteCenter()
+ }
+ itemBuilders.forEachIndexed { i, itemBuilder ->
+ radialListItem(itemBuilder, i, offsets[i])
+ }
+ }
+}
+
+private fun RBuilder.radialListItem(itemBuilder: ElementBuilder, i: Int, offset: CartesianCoords): ReactElement {
+ return styledLi {
+ css {
+ display = Display.block
+ position = Position.absolute
+ top = 50.pct
+ left = 50.pct
+ zeroMargins()
+ listStyleType = ListStyleType.unset
+ transition("all", 500.ms, Timing.easeInOut)
+ zIndex = 1
+ transform {
+ translate(offset.x.px, offset.y.px)
+ translate((-50).pct, (-50).pct)
+ }
+ }
+ attrs {
+ key = "$i"
+ }
+ itemBuilder()
+ }
+}
+
+private fun RBuilder.radialListCenter(centerElement: ElementBuilder?): ReactElement? {
+ if (centerElement == null) {
+ return null
+ }
+ return styledDiv {
+ css {
+ zIndex = 0
+ absoluteCenter()
+ }
+ centerElement()
+ }
+}
+
+private fun CSSBuilder.absoluteCenter() {
+ position = Position.absolute
+ left = 50.pct
+ top = 50.pct
+ transform {
+ translate((-50).pct, (-50).pct)
+ }
+}
+
+private fun CSSBuilder.zeroMargins() {
+ margin = "0"
+ padding = "0"
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt
new file mode 100644
index 00000000..d668ab9b
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt
@@ -0,0 +1,57 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.roundToInt
+import kotlin.math.sin
+
+data class CartesianCoords(
+ val x: Int,
+ val y: Int
+)
+
+data class PolarCoords(
+ val radius: Int,
+ val angleDeg: Int
+)
+
+private fun Int.toRadians() = (this * PI / 180.0)
+private fun Double.project(angleRad: Double, trigFn: (Double) -> Double) = (this * trigFn(angleRad)).roundToInt()
+private fun Double.xProjection(angleRad: Double) = project(angleRad, ::cos)
+private fun Double.yProjection(angleRad: Double) = project(angleRad, ::sin)
+
+private fun PolarCoords.toCartesian() = CartesianCoords(
+ x = radius.toDouble().xProjection(angleDeg.toRadians()),
+ y = radius.toDouble().yProjection(angleDeg.toRadians())
+)
+
+// Y-axis is pointing down in the browser, so the directions need to be reversed
+// (positive angles are now clockwise)
+enum class Direction(private val value: Int) {
+ CLOCKWISE(1),
+ COUNTERCLOCKWISE(-1);
+
+ fun toOrientedDegrees(deg: Int) = value * deg
+}
+
+data class RadialConfig(
+ val radius: Int = 120,
+ val spreadArcDegrees: Int = 360, // full circle
+ val firstItemAngleDegrees: Int = 0, // 12 o'clock
+ val direction: Direction = Direction.CLOCKWISE
+) {
+ val diameter: Int = radius * 2
+}
+
+private const val DEFAULT_START = -90 // Up, because Y-axis is reversed
+
+fun offsetsFromCenter(nbItems: Int, radialConfig: RadialConfig = RadialConfig()): List<CartesianCoords> {
+ val startAngle = DEFAULT_START + radialConfig.direction.toOrientedDegrees(radialConfig.firstItemAngleDegrees)
+ val angleStep = radialConfig.spreadArcDegrees / nbItems
+ return List(nbItems) { itemCartesianOffsets(startAngle, angleStep, it, radialConfig) }
+}
+
+private fun itemCartesianOffsets(startAngle: Int, angleStep: Int, index: Int, config: RadialConfig): CartesianCoords {
+ val itemAngle = startAngle + config.direction.toOrientedDegrees(angleStep) * index
+ return PolarCoords(config.radius, itemAngle).toCartesian()
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt
new file mode 100644
index 00000000..ff541696
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt
@@ -0,0 +1,106 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import com.palantir.blueprintjs.IconName
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpIcon
+import kotlinx.css.Align
+import kotlinx.css.Display
+import kotlinx.css.FlexDirection
+import kotlinx.css.alignItems
+import kotlinx.css.display
+import kotlinx.css.flexDirection
+import kotlinx.css.margin
+import kotlinx.css.opacity
+import org.luxons.sevenwonders.model.api.PlayerDTO
+import react.RBuilder
+import react.ReactElement
+import react.dom.*
+import styled.css
+import styled.styledDiv
+import styled.styledH5
+
+fun RBuilder.radialPlayerList(players: List<PlayerDTO>, currentPlayer: PlayerDTO): ReactElement {
+ val playerItemBuilders = players
+ .growTo(targetSize = 3)
+ .withUserFirst(currentPlayer)
+ .map { p -> p.elementBuilder(p?.username == currentPlayer.username) }
+
+ val tableImgBuilder: ElementBuilder = { roundTableImg() }
+
+ return radialList(
+ itemBuilders = playerItemBuilders,
+ centerElementBuilder = tableImgBuilder,
+ itemWidth = 120,
+ itemHeight = 100,
+ options = RadialConfig(
+ radius = 175,
+ firstItemAngleDegrees = 180 // self at the bottom
+ )
+ )
+}
+
+private fun RBuilder.roundTableImg(): ReactElement = img {
+ attrs {
+ src = "images/round-table.png"
+ alt = "Round table"
+ width = "200"
+ height = "200"
+ }
+}
+
+private fun List<PlayerDTO?>.withUserFirst(me: PlayerDTO): List<PlayerDTO?> {
+ val nonUsersBeginning = takeWhile { it?.username != me.username }
+ val userToEnd = subList(nonUsersBeginning.size, size)
+ return userToEnd + nonUsersBeginning
+}
+
+private fun <T> List<T>.growTo(targetSize: Int): List<T?> {
+ if (size >= targetSize) return this
+ return this + List(targetSize - size) { null }
+}
+
+private fun PlayerDTO?.elementBuilder(isMe: Boolean): ElementBuilder {
+ if (this == null) {
+ return { playerPlaceholder() }
+ } else {
+ return { playerItem(this@elementBuilder, isMe) }
+ }
+}
+
+private fun RBuilder.playerItem(player: PlayerDTO, isMe: Boolean): ReactElement = styledDiv {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = Align.center
+ }
+ val title = if (player.isGameOwner) "Game owner" else null
+ userIcon(isMe = isMe, isOwner = player.isGameOwner, title = title)
+ styledH5 {
+ css {
+ margin = "0"
+ }
+ +player.displayName
+ }
+}
+
+private fun RBuilder.playerPlaceholder(): ReactElement = styledDiv {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = Align.center
+ opacity = 0.3
+ }
+ userIcon(isMe = false, isOwner = false, title = "Waiting for player...")
+ styledH5 {
+ css {
+ margin = "0"
+ }
+ +"?"
+ }
+}
+
+private fun RBuilder.userIcon(isMe: Boolean, isOwner: Boolean, title: String?): ReactElement {
+ val iconName: IconName = if (isOwner) "badge" else "user"
+ val intent: Intent = if (isMe) Intent.WARNING else Intent.NONE
+ return bpIcon(name = iconName, intent = intent, size = 50, title = title)
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt
new file mode 100644
index 00000000..3e3de561
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt
@@ -0,0 +1,26 @@
+package org.luxons.sevenwonders.ui.redux
+
+import org.luxons.sevenwonders.model.PlayerMove
+import org.luxons.sevenwonders.model.PlayerTurnInfo
+import org.luxons.sevenwonders.model.api.ConnectedPlayer
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.model.cards.PreparedCard
+import redux.RAction
+
+data class SetCurrentPlayerAction(val player: ConnectedPlayer): RAction
+
+data class UpdateGameListAction(val games: List<LobbyDTO>): RAction
+
+data class UpdateLobbyAction(val lobby: LobbyDTO): RAction
+
+data class EnterLobbyAction(val lobby: LobbyDTO): RAction
+
+data class EnterGameAction(val lobby: LobbyDTO, val turnInfo: PlayerTurnInfo): RAction
+
+data class TurnInfoEvent(val turnInfo: PlayerTurnInfo): RAction
+
+data class PreparedMoveEvent(val move: PlayerMove): RAction
+
+data class PreparedCardEvent(val card: PreparedCard): RAction
+
+data class PlayerReadyEvent(val username: String): RAction
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt
new file mode 100644
index 00000000..836f5b4e
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt
@@ -0,0 +1,23 @@
+package org.luxons.sevenwonders.ui.redux
+
+import org.luxons.sevenwonders.model.CustomizableSettings
+import org.luxons.sevenwonders.model.PlayerMove
+import redux.RAction
+
+data class RequestChooseName(val playerName: String): RAction
+
+data class RequestCreateGame(val gameName: String): RAction
+
+data class RequestJoinGame(val gameId: Long): RAction
+
+data class RequestReorderPlayers(val orderedPlayers: List<String>): RAction
+
+data class RequestUpdateSettings(val settings: CustomizableSettings): RAction
+
+class RequestStartGame: RAction
+
+class RequestSayReady: RAction
+
+data class RequestPrepareMove(val move: PlayerMove): RAction
+
+class RequestUnprepareMove: RAction
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt
new file mode 100644
index 00000000..c21f6deb
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt
@@ -0,0 +1,84 @@
+package org.luxons.sevenwonders.ui.redux
+
+import org.luxons.sevenwonders.model.PlayerMove
+import org.luxons.sevenwonders.model.PlayerTurnInfo
+import org.luxons.sevenwonders.model.api.ConnectedPlayer
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.model.api.PlayerDTO
+import org.luxons.sevenwonders.model.api.State
+import org.luxons.sevenwonders.model.cards.CardBack
+import org.luxons.sevenwonders.model.cards.HandCard
+import redux.RAction
+
+data class SwState(
+ val connectedPlayer: ConnectedPlayer? = null,
+ // they must be by ID to support updates to a sublist
+ val gamesById: Map<Long, LobbyDTO> = emptyMap(),
+ val currentLobby: LobbyDTO? = null,
+ val gameState: GameState? = null
+) {
+ val currentPlayer: PlayerDTO? = (gameState?.players ?: currentLobby?.players)?.first {
+ it.username == connectedPlayer?.username
+ }
+ val games: List<LobbyDTO> = gamesById.values.toList()
+}
+
+data class GameState(
+ val id: Long,
+ val players: List<PlayerDTO>,
+ val turnInfo: PlayerTurnInfo?,
+ val preparedCardsByUsername: Map<String, CardBack?> = emptyMap(),
+ val currentPreparedMove: PlayerMove? = null
+) {
+ val currentPreparedCard: HandCard?
+ get() = turnInfo?.hand?.firstOrNull { it.name == currentPreparedMove?.cardName }
+}
+
+fun rootReducer(state: SwState, action: RAction): SwState = state.copy(
+ gamesById = gamesReducer(state.gamesById, action),
+ connectedPlayer = currentPlayerReducer(state.connectedPlayer, action),
+ currentLobby = currentLobbyReducer(state.currentLobby, action),
+ gameState = gameStateReducer(state.gameState, action)
+)
+
+private fun gamesReducer(games: Map<Long, LobbyDTO>, action: RAction): Map<Long, LobbyDTO> = when (action) {
+ is UpdateGameListAction -> (games + action.games.associateBy { it.id }).filterValues { it.state != State.FINISHED }
+ else -> games
+}
+
+private fun currentPlayerReducer(currentPlayer: ConnectedPlayer?, action: RAction): ConnectedPlayer? = when (action) {
+ is SetCurrentPlayerAction -> action.player
+ else -> currentPlayer
+}
+
+private fun currentLobbyReducer(currentLobby: LobbyDTO?, action: RAction): LobbyDTO? = when (action) {
+ is EnterLobbyAction -> action.lobby
+ is UpdateLobbyAction -> action.lobby
+ is PlayerReadyEvent -> currentLobby?.let { l ->
+ l.copy(players = l.players.map { p ->
+ if (p.username == action.username) p.copy(isReady = true) else p
+ })
+ }
+ else -> currentLobby
+}
+
+private fun gameStateReducer(gameState: GameState?, action: RAction): GameState? = when (action) {
+ is EnterGameAction -> GameState(
+ id = action.lobby.id,
+ players = action.lobby.players,
+ turnInfo = action.turnInfo
+ )
+ is PreparedMoveEvent -> gameState?.copy(currentPreparedMove = action.move)
+ is RequestUnprepareMove -> gameState?.copy(currentPreparedMove = null)
+ is PreparedCardEvent -> gameState?.copy(
+ preparedCardsByUsername = gameState.preparedCardsByUsername + (action.card.player.username to action.card.cardBack)
+ )
+ is PlayerReadyEvent -> gameState?.copy(players = gameState.players.map { p ->
+ if (p.username == action.username) p.copy(isReady = true) else p
+ })
+ is TurnInfoEvent -> gameState?.copy(
+ players = gameState.players.map { p -> p.copy(isReady = false) },
+ turnInfo = action.turnInfo
+ )
+ else -> gameState
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt
new file mode 100644
index 00000000..6f50a627
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt
@@ -0,0 +1,29 @@
+package org.luxons.sevenwonders.ui.redux
+
+import org.luxons.sevenwonders.ui.redux.sagas.SagaManager
+import redux.RAction
+import redux.Store
+import redux.WrapperAction
+import redux.applyMiddleware
+import redux.compose
+import redux.createStore
+import redux.rEnhancer
+import kotlin.browser.window
+
+val INITIAL_STATE = SwState()
+
+private fun <A, T1, R> composeWithDevTools(function1: (T1) -> R, function2: (A) -> T1): (A) -> R {
+ val reduxDevtoolsExtensionCompose = window.asDynamic().__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
+ if (reduxDevtoolsExtensionCompose == undefined) {
+ return compose(function1, function2)
+ }
+ return reduxDevtoolsExtensionCompose(function1, function2) as Function1<A, R>
+}
+
+fun configureStore(
+ sagaManager: SagaManager<SwState, RAction, WrapperAction>,
+ initialState: SwState = INITIAL_STATE
+): Store<SwState, RAction, WrapperAction> {
+ val sagaEnhancer = applyMiddleware(sagaManager.createMiddleware())
+ return createStore(::rootReducer, initialState, composeWithDevTools(sagaEnhancer, rEnhancer()))
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt
new file mode 100644
index 00000000..67ac5304
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt
@@ -0,0 +1,39 @@
+package org.luxons.sevenwonders.ui.redux
+
+import react.RClass
+import react.RComponent
+import react.RProps
+import react.RState
+import react.invoke
+import react.redux.rConnect
+import redux.RAction
+import redux.WrapperAction
+import kotlin.reflect.KClass
+
+inline fun <reified DP : RProps> connectDispatch(
+ clazz: KClass<out RComponent<DP, out RState>>,
+ noinline mapDispatchToProps: DP.((RAction) -> WrapperAction, RProps) -> Unit
+): RClass<RProps> {
+ val connect = rConnect(mapDispatchToProps = mapDispatchToProps)
+ return connect.invoke(clazz.js.unsafeCast<RClass<DP>>())
+}
+
+inline fun <reified SP : RProps> connectState(
+ clazz: KClass<out RComponent<SP, out RState>>,
+ noinline mapStateToProps: SP.(SwState, RProps) -> Unit
+): RClass<RProps> {
+ val connect = rConnect(mapStateToProps = mapStateToProps)
+ return connect.invoke(clazz.js.unsafeCast<RClass<SP>>())
+}
+
+inline fun <reified SP : RProps, reified DP : RProps, reified P : RProps> connectStateAndDispatch(
+ clazz: KClass<out RComponent<P, out RState>>,
+ noinline mapStateToProps: SP.(SwState, RProps) -> Unit,
+ noinline mapDispatchToProps: DP.((RAction) -> WrapperAction, RProps) -> Unit
+): RClass<RProps> {
+ val connect = rConnect<SwState, RAction, WrapperAction, RProps, SP, DP, P>(
+ mapStateToProps = mapStateToProps,
+ mapDispatchToProps = mapDispatchToProps
+ )
+ return connect.invoke(clazz.js.unsafeCast<RClass<P>>())
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt
new file mode 100644
index 00000000..7806bc98
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt
@@ -0,0 +1,50 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import org.hildan.krossbow.stomp.StompSubscription
+import org.luxons.sevenwonders.client.SevenWondersSession
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.ui.redux.EnterLobbyAction
+import org.luxons.sevenwonders.ui.redux.RequestCreateGame
+import org.luxons.sevenwonders.ui.redux.RequestJoinGame
+import org.luxons.sevenwonders.ui.redux.UpdateGameListAction
+import org.luxons.sevenwonders.ui.router.Navigate
+import org.luxons.sevenwonders.ui.router.Route
+import org.luxons.sevenwonders.ui.utils.awaitFirst
+
+suspend fun SwSagaContext.gameBrowserSaga(session: SevenWondersSession) {
+ GameBrowserSaga(session, this).run()
+}
+
+private class GameBrowserSaga(
+ private val session: SevenWondersSession,
+ private val sagaContext: SwSagaContext
+) {
+ suspend fun run() {
+ coroutineScope {
+ val gamesSubscription = session.watchGames()
+ launch { dispatchGameUpdates(gamesSubscription) }
+ val lobby = awaitCreateOrJoinGame()
+ gamesSubscription.unsubscribe()
+ sagaContext.dispatch(EnterLobbyAction(lobby))
+ sagaContext.dispatch(Navigate(Route.LOBBY))
+ }
+ }
+
+ private suspend fun dispatchGameUpdates(gamesSubscription: StompSubscription<List<LobbyDTO>>) {
+ sagaContext.dispatchAll(gamesSubscription.messages) { UpdateGameListAction(it) }
+ }
+
+ private suspend fun awaitCreateOrJoinGame(): LobbyDTO = awaitFirst(this::awaitCreateGame, this::awaitJoinGame)
+
+ private suspend fun awaitCreateGame(): LobbyDTO {
+ val action = sagaContext.next<RequestCreateGame>()
+ return session.createGame(action.gameName)
+ }
+
+ private suspend fun awaitJoinGame(): LobbyDTO {
+ val action = sagaContext.next<RequestJoinGame>()
+ return session.joinGame(action.gameId)
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt
new file mode 100644
index 00000000..a9c2ca2c
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt
@@ -0,0 +1,36 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import org.luxons.sevenwonders.client.SevenWondersSession
+import org.luxons.sevenwonders.ui.redux.PlayerReadyEvent
+import org.luxons.sevenwonders.ui.redux.PreparedCardEvent
+import org.luxons.sevenwonders.ui.redux.PreparedMoveEvent
+import org.luxons.sevenwonders.ui.redux.RequestPrepareMove
+import org.luxons.sevenwonders.ui.redux.RequestSayReady
+import org.luxons.sevenwonders.ui.redux.RequestUnprepareMove
+import org.luxons.sevenwonders.ui.redux.TurnInfoEvent
+
+suspend fun SwSagaContext.gameSaga(session: SevenWondersSession) {
+ val game = getState().gameState ?: error("Game saga run without a current game")
+ coroutineScope {
+ val playerReadySub = session.watchPlayerReady(game.id)
+ val preparedCardsSub = session.watchPreparedCards(game.id)
+ val turnInfoSub = session.watchTurns()
+ val sayReadyJob = launch { onEach<RequestSayReady> { session.sayReady() } }
+ val unprepareJob = launch { onEach<RequestUnprepareMove> { session.unprepareMove() } }
+ val prepareMoveJob = launch {
+ onEach<RequestPrepareMove> {
+ val move = session.prepareMove(it.move)
+ dispatch(PreparedMoveEvent(move))
+ }
+ }
+ launch { dispatchAll(playerReadySub.messages) { PlayerReadyEvent(it) } }
+ launch { dispatchAll(preparedCardsSub.messages) { PreparedCardEvent(it) } }
+ launch { dispatchAll(turnInfoSub.messages) { TurnInfoEvent(it) } }
+ // TODO await game end
+ // TODO unsubscribe all subs, cancel all jobs
+ }
+ console.log("End of game saga")
+}
+
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt
new file mode 100644
index 00000000..678276dc
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt
@@ -0,0 +1,42 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import org.hildan.krossbow.stomp.StompSubscription
+import org.luxons.sevenwonders.client.SevenWondersSession
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.ui.redux.EnterGameAction
+import org.luxons.sevenwonders.ui.redux.RequestStartGame
+import org.luxons.sevenwonders.ui.redux.UpdateLobbyAction
+import org.luxons.sevenwonders.ui.router.Navigate
+import org.luxons.sevenwonders.ui.router.Route
+
+suspend fun SwSagaContext.lobbySaga(session: SevenWondersSession) {
+ val lobby = getState().currentLobby ?: error("Lobby saga run without a current lobby")
+ coroutineScope {
+ val lobbyUpdatesSubscription = session.watchLobbyUpdates()
+ launch { watchLobbyUpdates(lobbyUpdatesSubscription) }
+ val startGameJob = launch { awaitStartGame(session) }
+
+ awaitGameStart(session, lobby.id)
+
+ lobbyUpdatesSubscription.unsubscribe()
+ startGameJob.cancel()
+ dispatch(Navigate(Route.GAME))
+ }
+}
+
+private suspend fun SwSagaContext.watchLobbyUpdates(lobbyUpdatesSubscription: StompSubscription<LobbyDTO>) {
+ dispatchAll(lobbyUpdatesSubscription.messages) { UpdateLobbyAction(it) }
+}
+
+private suspend fun SwSagaContext.awaitGameStart(session: SevenWondersSession, lobbyId: Long) {
+ val turnInfo = session.awaitGameStart(lobbyId)
+ val lobby = getState().currentLobby!!
+ dispatch(EnterGameAction(lobby, turnInfo))
+}
+
+private suspend fun SwSagaContext.awaitStartGame(session: SevenWondersSession) {
+ next<RequestStartGame>()
+ session.startGame()
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt
new file mode 100644
index 00000000..c4a92581
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt
@@ -0,0 +1,54 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.yield
+import org.luxons.sevenwonders.client.SevenWondersClient
+import org.luxons.sevenwonders.client.SevenWondersSession
+import org.luxons.sevenwonders.ui.redux.RequestChooseName
+import org.luxons.sevenwonders.ui.redux.SetCurrentPlayerAction
+import org.luxons.sevenwonders.ui.redux.SwState
+import org.luxons.sevenwonders.ui.router.Route
+import org.luxons.sevenwonders.ui.router.routerSaga
+import redux.RAction
+import redux.WrapperAction
+
+typealias SwSagaContext = SagaContext<SwState, RAction, WrapperAction>
+
+suspend fun SwSagaContext.rootSaga() = coroutineScope {
+ val action = next<RequestChooseName>()
+ val session = SevenWondersClient().connect("localhost:8000")
+ console.info("Connected to Seven Wonders web socket API")
+
+ launch {
+ serverErrorSaga(session)
+ }
+ yield() // ensures the error saga starts
+
+ val player = session.chooseName(action.playerName)
+ dispatch(SetCurrentPlayerAction(player))
+
+ routerSaga(Route.GAME_BROWSER) {
+ when (it) {
+ Route.HOME -> homeSaga(session)
+ Route.LOBBY -> lobbySaga(session)
+ Route.GAME_BROWSER -> gameBrowserSaga(session)
+ Route.GAME -> gameSaga(session)
+ }
+ }
+}
+
+private suspend fun serverErrorSaga(session: SevenWondersSession) {
+ val errorsSub = session.watchErrors()
+ for (err in errorsSub.messages) {
+ // TODO use blueprintjs toaster
+ console.error("${err.code}: ${err.message}")
+ console.error(JSON.stringify(err))
+ }
+}
+
+private suspend fun SwSagaContext.homeSaga(session: SevenWondersSession) {
+ val action = next<RequestChooseName>()
+ val player = session.chooseName(action.playerName)
+ dispatch(SetCurrentPlayerAction(player))
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt
new file mode 100644
index 00000000..1a57708e
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt
@@ -0,0 +1,137 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.BroadcastChannel
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.launch
+import redux.Middleware
+import redux.MiddlewareApi
+import redux.RAction
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SagaManager<S, A : RAction, R>(
+ private val monitor: ((A) -> Unit)? = null
+) {
+ private lateinit var context: SagaContext<S, A, R>
+
+ private val actions = BroadcastChannel<A>(16)
+
+ fun createMiddleware(): Middleware<S, A, R, A, R> = ::sagasMiddleware
+
+ private fun sagasMiddleware(api: MiddlewareApi<S, A, R>): ((A) -> R) -> (A) -> R {
+ context = SagaContext(api, actions)
+ return { nextDispatch ->
+ { action ->
+ onActionDispatched(action)
+ val result = nextDispatch(action)
+ handleAction(action)
+ result
+ }
+ }
+ }
+
+ private fun onActionDispatched(action: A) {
+ monitor?.invoke(action)
+ }
+
+ private fun handleAction(action: A) {
+ GlobalScope.launch { actions.send(action) }
+ }
+
+ fun launchSaga(coroutineScope: CoroutineScope, saga: suspend SagaContext<S, A, R>.() -> Unit): Job {
+ checkMiddlewareApplied()
+ return coroutineScope.launch {
+ context.saga()
+ }
+ }
+
+ suspend fun runSaga(saga: suspend SagaContext<S, A, R>.() -> Unit) {
+ checkMiddlewareApplied()
+ context.saga()
+ }
+
+ private fun checkMiddlewareApplied() {
+ check(::context.isInitialized) {
+ "Before running a Saga, you must mount the Saga middleware on the Store using applyMiddleware"
+ }
+ }
+}
+
+@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
+class SagaContext<S, A : RAction, R>(
+ private val reduxApi: MiddlewareApi<S, A, R>,
+ private val actions: BroadcastChannel<A>
+) {
+ /**
+ * Gets the current redux state.
+ */
+ fun getState(): S = reduxApi.getState()
+
+ /**
+ * Dispatches the given redux [action].
+ */
+ fun dispatch(action: A) {
+ reduxApi.dispatch(action)
+ }
+
+ /**
+ * Dispatches an action given by [createAction] for each message received in [channel].
+ */
+ suspend fun <T> dispatchAll(channel: ReceiveChannel<T>, createAction: (T) -> A) {
+ for (msg in channel) {
+ reduxApi.dispatch(createAction(msg))
+ }
+ }
+
+ /**
+ * Executes [handle] on every action dispatched. This runs forever until the current coroutine is cancelled.
+ */
+ suspend fun onEach(handle: suspend SagaContext<S, A, R>.(A) -> Unit) {
+ val channel = actions.openSubscription()
+ try {
+ for (a in channel) {
+ handle(a)
+ }
+ } finally {
+ channel.cancel()
+ }
+ }
+
+ /**
+ * Executes [handle] on every action dispatched of the type [T]. This runs forever until the current coroutine is
+ * cancelled.
+ */
+ suspend inline fun <reified T : A> onEach(
+ crossinline handle: suspend SagaContext<S, A, R>.(T) -> Unit
+ ) = onEach {
+ if (it is T) {
+ handle(it)
+ }
+ }
+
+ /**
+ * Suspends until the next action matching the given [predicate] is dispatched, and returns that action.
+ */
+ suspend fun next(predicate: (A) -> Boolean): A {
+ val channel = actions.openSubscription()
+ try {
+ for (a in channel) {
+ if (predicate(a)) {
+ return a
+ }
+ }
+ } finally {
+ channel.cancel()
+ }
+ error("Actions channel closed before receiving a matching action")
+ }
+
+ /**
+ * Suspends until the next action of type [T] is dispatched, and returns that action.
+ */
+ suspend inline fun <reified T : A> next(): T = next { it is T } as T
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt
new file mode 100644
index 00000000..19e8bd94
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt
@@ -0,0 +1,32 @@
+package org.luxons.sevenwonders.ui.router
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import org.luxons.sevenwonders.ui.redux.sagas.SwSagaContext
+import redux.RAction
+import kotlin.browser.window
+
+enum class Route(val path: String) {
+ HOME("/"),
+ GAME_BROWSER("/games"),
+ LOBBY("/lobby"),
+ GAME("/game"),
+}
+
+data class Navigate(val route: Route): RAction
+
+suspend fun SwSagaContext.routerSaga(
+ startRoute: Route,
+ runRouteSaga: suspend SwSagaContext.(Route) -> Unit
+) {
+ coroutineScope {
+ window.location.hash = startRoute.path
+ var currentSaga: Job = launch { runRouteSaga(startRoute) }
+ onEach<Navigate> {
+ currentSaga.cancel()
+ window.location.hash = it.route.path
+ currentSaga = launch { runRouteSaga(it.route) }
+ }
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt
new file mode 100644
index 00000000..600f08d3
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt
@@ -0,0 +1,15 @@
+package org.luxons.sevenwonders.ui.utils
+
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.selects.select
+
+// Cannot inline or it crashes for some reason
+suspend fun <R> awaitFirst(f1: suspend () -> R, f2: suspend () -> R): R = coroutineScope {
+ val deferred1 = async { f1() }
+ val deferred2 = async { f2() }
+ select<R> {
+ deferred1.onAwait { deferred2.cancel(); it }
+ deferred2.onAwait { deferred1.cancel(); it }
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt
new file mode 100644
index 00000000..07b3f2b5
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt
@@ -0,0 +1,16 @@
+package org.luxons.sevenwonders.ui.utils
+
+import kotlinx.html.SPAN
+import kotlinx.html.attributesMapOf
+import react.RBuilder
+import react.ReactElement
+import react.dom.*
+
+/**
+ * Creates a ReactElement without appending it (so that is can be passed around).
+ */
+fun createElement(block: RBuilder.() -> ReactElement): ReactElement {
+ return RDOMBuilder { SPAN(attributesMapOf("class", null), it) }
+ .apply { block() }
+ .create()
+}
bgstack15