summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.gradle.kts4
-rw-r--r--sw-client/src/commonMain/kotlin/org/luxons/sevenwonders/client/SevenWondersClient.kt6
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/server/SevenWondersTest.kt17
-rw-r--r--sw-ui-kt/build.gradle.kts7
-rw-r--r--sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt17
-rw-r--r--sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt4
-rw-r--r--sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt27
-rw-r--r--sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt19
-rw-r--r--sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt2
-rw-r--r--sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt14
-rw-r--r--sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt52
-rw-r--r--sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt15
-rw-r--r--sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt33
-rw-r--r--sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt20
-rw-r--r--sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt143
-rw-r--r--sw-ui-kt/src/test/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFrameworkTest.kt94
16 files changed, 453 insertions, 21 deletions
diff --git a/build.gradle.kts b/build.gradle.kts
index dc9c46be..af286b54 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -17,4 +17,8 @@ subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.jvmTarget = "1.8"
}
+
+ tasks.withType<org.jetbrains.kotlin.gradle.tasks.Kotlin2JsCompile> {
+ kotlinOptions.freeCompilerArgs = listOf("-Xuse-experimental=kotlin.Experimental")
+ }
}
diff --git a/sw-client/src/commonMain/kotlin/org/luxons/sevenwonders/client/SevenWondersClient.kt b/sw-client/src/commonMain/kotlin/org/luxons/sevenwonders/client/SevenWondersClient.kt
index 544c870b..70cefbcc 100644
--- a/sw-client/src/commonMain/kotlin/org/luxons/sevenwonders/client/SevenWondersClient.kt
+++ b/sw-client/src/commonMain/kotlin/org/luxons/sevenwonders/client/SevenWondersClient.kt
@@ -94,10 +94,8 @@ class SevenWondersSession(private val stompSession: KrossbowSession) {
suspend fun watchGameStart(gameId: Long): SevenWondersSubscription<Unit> =
stompSession.subscribe<Unit>("/topic/lobby/$gameId/started").ignoreHeaders()
- suspend fun startGame(gameId: Long) {
- val sendDestination = "/app/lobby/startGame"
- val receiveDestination = "/topic/lobby/$gameId/started"
- stompSession.request<Unit>(sendDestination, receiveDestination)
+ suspend fun startGame() {
+ stompSession.send("/app/lobby/startGame")
}
suspend fun watchPlayerReady(gameId: Long): SevenWondersSubscription<String> =
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/SevenWondersTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/SevenWondersTest.kt
index 4771076d..7b996680 100644
--- a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/SevenWondersTest.kt
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/SevenWondersTest.kt
@@ -69,7 +69,7 @@ class SevenWondersTest {
val outsiderSession = newPlayer("Outsider")
val (started) = outsiderSession.watchGameStart(lobby.id)
- ownerSession.startGame(lobby.id)
+ ownerSession.startGame()
val nothing = withTimeoutOrNull(30) { started.receive() }
assertNull(nothing)
disconnect(ownerSession, session1, session2, outsiderSession)
@@ -126,7 +126,14 @@ class SevenWondersTest {
val session3 = newPlayer("Player3")
session3.joinGame(lobby.id)
- session1.startGame(lobby.id)
+ val (gameStart1) = session1.watchGameStart(lobby.id)
+ val (gameStart2) = session2.watchGameStart(lobby.id)
+ val (gameStart3) = session3.watchGameStart(lobby.id)
+ session1.startGame()
+
+ withTimeout(500) { gameStart1.receive() }
+ withTimeout(500) { gameStart2.receive() }
+ withTimeout(500) { gameStart3.receive() }
val (turns1) = session1.watchTurns()
val (turns2) = session2.watchTurns()
@@ -134,9 +141,9 @@ class SevenWondersTest {
session1.sayReady()
session2.sayReady()
session3.sayReady()
- val turn1 = turns1.receive()
- val turn2 = turns2.receive()
- val turn3 = turns3.receive()
+ val turn1 = withTimeout(500) { turns1.receive() }
+ val turn2 = withTimeout(500) { turns2.receive() }
+ val turn3 = withTimeout(500) { turns3.receive() }
assertNotNull(turn1)
assertNotNull(turn2)
assertNotNull(turn3)
diff --git a/sw-ui-kt/build.gradle.kts b/sw-ui-kt/build.gradle.kts
index 04bf8cf8..d2467ef7 100644
--- a/sw-ui-kt/build.gradle.kts
+++ b/sw-ui-kt/build.gradle.kts
@@ -43,7 +43,12 @@ kotlin {
// seems to be required by "kotlin-extensions" JS lib
implementation(npm("core-js", "3.1.4"))
- // implementation(npm("@blueprintjs/core", "3.15.1"))
+ implementation(npm("@blueprintjs/core", "3.15.1"))
+ }
+ }
+ test {
+ dependencies {
+ implementation(kotlin("test-js"))
}
}
}
diff --git a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt
index 2a075ba9..fafa2333 100644
--- a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt
+++ b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt
@@ -1,11 +1,16 @@
package org.luxons.sevenwonders.ui
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.RBuilder
import react.dom.*
import react.redux.provider
+import redux.RAction
+import redux.Store
+import redux.WrapperAction
import kotlin.browser.document
import kotlin.browser.window
@@ -21,10 +26,18 @@ fun main() {
}
private fun initializeAndRender(rootElement: Element) {
- val store = configureStore()
+ 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)
+ sagaManager.startSaga(rootSaga())
+ return store
+}
diff --git a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt
index 8bf43f1d..30a0883a 100644
--- a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt
+++ b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt
@@ -3,7 +3,7 @@ package org.luxons.sevenwonders.ui.components.home
import kotlinx.html.InputType
import kotlinx.html.js.onChangeFunction
import kotlinx.html.js.onSubmitFunction
-import org.luxons.sevenwonders.ui.redux.ChooseUserName
+import org.luxons.sevenwonders.ui.redux.RequestChooseName
import org.luxons.sevenwonders.ui.redux.connectDispatch
import react.RBuilder
import react.RClass
@@ -37,5 +37,5 @@ private class ChooseNameForm(props: ChooseNameFormProps): RComponent<ChooseNameF
}
val chooseNameForm: RClass<RProps> = connectDispatch(ChooseNameForm::class) { dispatch, _ ->
- chooseUsername = { name -> dispatch(ChooseUserName(name)) }
+ chooseUsername = { name -> dispatch(RequestChooseName(name)) }
}
diff --git a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt
index 3fc51d6e..27c451be 100644
--- a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt
+++ b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt
@@ -1,5 +1,30 @@
package org.luxons.sevenwonders.ui.redux
+import org.luxons.sevenwonders.model.GameState
+import org.luxons.sevenwonders.model.PlayerMove
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.model.api.PlayerDTO
+import org.luxons.sevenwonders.model.cards.PreparedCard
import redux.RAction
-class ChooseUserName(val newUsername: String): RAction
+data class SetCurrentPlayerAction(val player: PlayerDTO): RAction
+
+data class UpdateGameListAction(val games: List<LobbyDTO>): RAction
+
+data class UpdateLobbyAction(val lobby: LobbyDTO): RAction
+
+data class EnterLobbyAction(val gameId: Long): RAction
+
+data class UpdatePlayers(val players: Map<String, PlayerDTO>): RAction
+
+data class EnterGameAction(val gameId: Long): RAction
+
+data class TurnInfoEvent(val players: Map<String, PlayerDTO>): RAction
+
+data class PrepareMoveAction(val move: PlayerMove): RAction
+
+data class PreparedCardEvent(val card: PreparedCard): RAction
+
+data class PlayerReadyEvent(val username: String): RAction
+
+data class TableUpdateEvent(val table: GameState): RAction
diff --git a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt
new file mode 100644
index 00000000..dc959a2f
--- /dev/null
+++ b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt
@@ -0,0 +1,19 @@
+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 RequestCreateGameAction(val gameName: String): RAction
+
+data class RequestJoinGameAction(val gameId: Long): RAction
+
+data class RequestReorderPlayers(val orderedPlayers: List<String>): RAction
+
+data class RequestUpdateSettings(val settings: CustomizableSettings): RAction
+
+class RequestStartGameAction: RAction
+
+data class PrepareMove(val move: PlayerMove): RAction
diff --git a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt
index d811d32e..f9f5a26d 100644
--- a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt
+++ b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt
@@ -3,6 +3,6 @@ package org.luxons.sevenwonders.ui.redux
import redux.RAction
fun rootReducer(state: SwState, action: RAction) = when (action) {
- is ChooseUserName -> state.copy(playerName = action.newUsername)
+ is RequestChooseName -> state.copy(playerName = action.playerName)
else -> state
}
diff --git a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt
index 75f7e8a8..fea13f04 100644
--- a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt
+++ b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt
@@ -1,8 +1,11 @@
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
@@ -12,9 +15,10 @@ data class SwState(
val INITIAL_STATE = SwState("Bob")
-fun configureStore(initialState: SwState = INITIAL_STATE): Store<SwState, RAction, WrapperAction> {
-
- // TODO configure middlewares
-
- return createStore(::rootReducer, initialState, rEnhancer())
+fun configureStore(
+ sagaManager: SagaManager<SwState, RAction, WrapperAction>,
+ initialState: SwState = INITIAL_STATE
+): Store<SwState, RAction, WrapperAction> {
+ val sagaEnhancer = applyMiddleware(sagaManager.createMiddleware())
+ return createStore(::rootReducer, initialState, compose(sagaEnhancer, rEnhancer()))
}
diff --git a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt
new file mode 100644
index 00000000..cd9e0cf8
--- /dev/null
+++ b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt
@@ -0,0 +1,52 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.isActive
+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.RequestCreateGameAction
+import org.luxons.sevenwonders.ui.redux.RequestJoinGameAction
+import org.luxons.sevenwonders.ui.redux.SwState
+import org.luxons.sevenwonders.ui.redux.UpdateGameListAction
+import org.luxons.sevenwonders.ui.redux.UpdateLobbyAction
+import redux.RAction
+import redux.WrapperAction
+
+fun gameBrowserSaga(session: SevenWondersSession) = saga<SwState, RAction, WrapperAction> {
+ val watchGamesJob = fork(watchGames(session))
+ val watchCreateGameJob = fork(watchCreateGame(session))
+ val watchJoinGameJob = fork(watchJoinGame(session))
+}
+
+private fun watchGames(session: SevenWondersSession) = saga<SwState, RAction, WrapperAction> {
+ val gamesSubscription = session.watchGames()
+ for (lobbies in gamesSubscription.messages) {
+ if (!isActive) {
+ gamesSubscription.unsubscribe()
+ break
+ }
+ dispatch(UpdateGameListAction(lobbies.toList()))
+ }
+}
+
+private fun watchCreateGame(session: SevenWondersSession) =
+ actionHandlerSaga<SwState, RAction, WrapperAction, RequestCreateGameAction> {
+ val lobby = session.createGame(it.gameName)
+ handleGameJoined(session, lobby)
+ }
+
+private fun watchJoinGame(session: SevenWondersSession) = actionHandlerSaga<SwState, RAction, WrapperAction, RequestJoinGameAction> {
+ val lobby = session.joinGame(it.gameId)
+ handleGameJoined(session, lobby)
+}
+
+private fun SagaContext<SwState, RAction, WrapperAction>.handleGameJoined(
+ session: SevenWondersSession,
+ lobby: LobbyDTO
+) {
+ dispatch(UpdateLobbyAction(lobby))
+ dispatch(EnterLobbyAction(lobby.id))
+ fork(lobbySaga(session, lobby.id))
+ // TODO push /lobby/{lobby.id}
+}
diff --git a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt
new file mode 100644
index 00000000..24a9e1b4
--- /dev/null
+++ b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt
@@ -0,0 +1,15 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import org.luxons.sevenwonders.client.SevenWondersSession
+import org.luxons.sevenwonders.ui.redux.SwState
+import redux.RAction
+import redux.WrapperAction
+
+fun gameSaga(session: SevenWondersSession, gameId: Long) = saga<SwState, RAction, WrapperAction> {
+ fork(watchPlayerReady(session, gameId))
+}
+
+fun watchPlayerReady(session: SevenWondersSession, gameId: Long) = saga<SwState, RAction, WrapperAction> {
+ session.watchPlayerReady(gameId)
+}
+
diff --git a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt
new file mode 100644
index 00000000..89e8e890
--- /dev/null
+++ b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt
@@ -0,0 +1,33 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import org.luxons.sevenwonders.client.SevenWondersSession
+import org.luxons.sevenwonders.ui.redux.EnterGameAction
+import org.luxons.sevenwonders.ui.redux.RequestStartGameAction
+import org.luxons.sevenwonders.ui.redux.SwState
+import org.luxons.sevenwonders.ui.redux.UpdateLobbyAction
+import redux.RAction
+import redux.WrapperAction
+
+fun lobbySaga(session: SevenWondersSession, lobbyId: Long) = saga<SwState, RAction, WrapperAction> {
+ fork(watchLobbyUpdates(session, lobbyId))
+ fork(watchGameStart(session, lobbyId))
+ fork(startGame(session))
+}
+
+private fun watchLobbyUpdates(session: SevenWondersSession, lobbyId: Long) = saga<SwState, RAction, WrapperAction> {
+ val lobbyUpdates = session.watchLobbyUpdates(lobbyId)
+ for (lobby in lobbyUpdates.messages) {
+ dispatch(UpdateLobbyAction(lobby))
+ }
+}
+
+private fun watchGameStart(session: SevenWondersSession, lobbyId: Long) = saga<SwState, RAction, WrapperAction> {
+ val gameStartSubscription = session.watchGameStart(lobbyId)
+ gameStartSubscription.messages.receive()
+ dispatch(EnterGameAction(lobbyId))
+ // TODO push /game/{lobby.id}
+}
+
+private fun startGame(session: SevenWondersSession) = actionHandlerSaga<SwState, RAction, WrapperAction, RequestStartGameAction> {
+ session.startGame()
+}
diff --git a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt
new file mode 100644
index 00000000..96311f98
--- /dev/null
+++ b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt
@@ -0,0 +1,20 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import org.luxons.sevenwonders.client.SevenWondersClient
+import org.luxons.sevenwonders.model.api.SEVEN_WONDERS_WS_ENDPOINT
+import org.luxons.sevenwonders.ui.redux.RequestChooseName
+import org.luxons.sevenwonders.ui.redux.SetCurrentPlayerAction
+import org.luxons.sevenwonders.ui.redux.SwState
+import redux.RAction
+import redux.WrapperAction
+
+fun rootSaga() = saga<SwState, RAction, WrapperAction> {
+ val action = next<RequestChooseName>()
+ val session = SevenWondersClient().connect(SEVEN_WONDERS_WS_ENDPOINT)
+
+ fork(gameBrowserSaga(session))
+
+ val player = session.chooseName(action.playerName)
+ dispatch(SetCurrentPlayerAction(player))
+ // push /games
+}
diff --git a/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt
new file mode 100644
index 00000000..2aa677fe
--- /dev/null
+++ b/sw-ui-kt/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt
@@ -0,0 +1,143 @@
+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.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.promise
+import redux.Middleware
+import redux.MiddlewareApi
+import redux.RAction
+import kotlin.coroutines.CoroutineContext
+
+@UseExperimental(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.promise { actions.send(action) }
+ }
+
+ fun startSaga(saga: Saga<S, A, R>): Job {
+ check(::context.isInitialized) {
+ "Before running a Saga, you must mount the Saga middleware on the Store using applyMiddleware"
+ }
+ return context.launch {
+ saga.execute(context)
+ }
+ }
+
+ suspend fun runSaga(saga: Saga<S, A, R>) {
+ check(::context.isInitialized) {
+ "Before running a Saga, you must mount the Saga middleware on the Store using applyMiddleware"
+ }
+ saga.execute(context)
+ }
+}
+
+fun <S, A : RAction, R> saga(block: suspend SagaContext<S, A, R>.() -> Unit) = Saga(block)
+
+inline fun <S, A : RAction, R, reified AT : A> actionHandlerSaga(
+ noinline block: suspend SagaContext<S, A, R>.(AT) -> Unit
+) = saga<S, A, R> {
+ onEach<AT> {
+ block(it)
+ }
+}
+
+class Saga<S, A : RAction, R>(private val body: suspend SagaContext<S, A, R>.() -> Unit) {
+
+ internal suspend fun execute(context: SagaContext<S, A, R>) = context.body()
+}
+
+@UseExperimental(FlowPreview::class, ExperimentalCoroutinesApi::class)
+class SagaContext<S, A : RAction, R>(
+ private val reduxApi: MiddlewareApi<S, A, R>,
+ private val actions: BroadcastChannel<A>
+): CoroutineScope {
+
+ private val job = Job()
+
+ override val coroutineContext: CoroutineContext
+ get() = job
+
+ fun dispatch(action: A) {
+ reduxApi.dispatch(action)
+ }
+
+ /**
+ * Starts a concurrent coroutine that executes [handle] on every action dispatched.
+ * Returns an "unsubscribe" function.
+ */
+ suspend fun onEach(handle: suspend SagaContext<S, A, R>.(A) -> Unit) {
+ val channel = actions.openSubscription()
+ for (a in channel) {
+ if (!isActive) {
+ channel.cancel()
+ break
+ }
+ handle(a)
+ }
+ }
+
+ /**
+ * Starts a concurrent coroutine that executes [handle] on every action dispatched of the given type.
+ * Returns an "unsubscribe" function.
+ */
+ suspend inline fun <reified AT : A> onEach(
+ crossinline handle: suspend SagaContext<S, A, R>.(AT) -> Unit
+ ) = onEach {
+ if (it is AT) {
+ 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()
+ for (a in channel) {
+ if (predicate(a)) {
+ channel.cancel()
+ return a
+ }
+ }
+ throw IllegalStateException("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
+
+ fun fork(saga: Saga<S, A, R>) = SagaContext(reduxApi, actions).launch {
+ saga.execute(this@SagaContext)
+ }
+}
diff --git a/sw-ui-kt/src/test/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFrameworkTest.kt b/sw-ui-kt/src/test/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFrameworkTest.kt
new file mode 100644
index 00000000..d8efcc3a
--- /dev/null
+++ b/sw-ui-kt/src/test/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFrameworkTest.kt
@@ -0,0 +1,94 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.promise
+import redux.RAction
+import redux.Store
+import redux.WrapperAction
+import redux.applyMiddleware
+import redux.compose
+import redux.createStore
+import redux.rEnhancer
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+private data class State(val data: String)
+
+private data class UpdateData(val newData: String): RAction
+private class DuplicateData: RAction
+private class SideEffectAction(val data: String): RAction
+
+private fun reduce(state: State, action: RAction): State = when (action) {
+ is UpdateData -> State(action.newData)
+ is DuplicateData -> State(state.data + state.data)
+ else -> state
+}
+
+private fun configureTestStore(initialState: State): TestRedux {
+ val sagaMiddlewareFactory = SagaManager<State, RAction, WrapperAction>()
+ val sagaMiddleware = sagaMiddlewareFactory.createMiddleware()
+ val enhancers = compose(applyMiddleware(sagaMiddleware), rEnhancer())
+ val store = createStore(::reduce, initialState, enhancers)
+ return TestRedux(store, sagaMiddlewareFactory)
+}
+
+private data class TestRedux(
+ val store: Store<State, RAction, WrapperAction>,
+ val sagas: SagaManager<State, RAction, WrapperAction>
+)
+
+@UseExperimental(ExperimentalCoroutinesApi::class)
+class SagaContextTest {
+
+ @Test
+ fun dispatch(): dynamic = GlobalScope.promise {
+
+ val redux = configureTestStore(State("initial"))
+
+ val saga = saga<State, RAction, WrapperAction> {
+ dispatch(UpdateData("Bob"))
+ }
+ redux.sagas.runSaga(saga)
+
+ assertEquals(State("Bob"), redux.store.getState(), "state is not as expected")
+ }
+
+ @Test
+ fun next(): dynamic = GlobalScope.promise {
+ val redux = configureTestStore(State("initial"))
+
+ val saga = saga<State, RAction, WrapperAction> {
+ val action = next<SideEffectAction>()
+ dispatch(UpdateData("effect-${action.data}"))
+ }
+ redux.sagas.startSaga(saga)
+
+ assertEquals(State("initial"), redux.store.getState())
+
+ redux.store.dispatch(SideEffectAction("data"))
+ delay(50)
+ assertEquals(State("effect-data"), redux.store.getState())
+ }
+
+ @Test
+ fun onEach(): dynamic = GlobalScope.promise {
+
+ val redux = configureTestStore(State("initial"))
+
+ val saga = saga<State, RAction, WrapperAction> {
+ onEach<SideEffectAction> {
+ dispatch(UpdateData("effect-${it.data}"))
+ }
+ }
+ val job = redux.sagas.startSaga(saga)
+
+ assertEquals(State("initial"), redux.store.getState())
+
+ redux.store.dispatch(SideEffectAction("data"))
+ delay(50)
+ assertEquals(State("effect-data"), redux.store.getState())
+ job.cancel()
+ }
+}
bgstack15