diff options
Diffstat (limited to 'sw-ui-kt')
13 files changed, 435 insertions, 12 deletions
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() + } +} |