import {AppState} from "../state/AppState"; import {NetworkState, ServerConnectionState} from "../state/NetworkState"; import {SyncedState} from "../../../common/src/state/SyncedState"; import {applySyncableActions} from "../../../common/src/reducers/SyncedStateReducer"; import {clientReducer} from "./ClientReducer"; import { SERVER_ACT, SERVER_FAILED, SERVER_GOODBYE, SERVER_HELLO, SERVER_MALFORMED, SERVER_OK, SERVER_REFRESH, SERVER_SOCKET_STARTUP, ServerActCommand, ServerAction, ServerFailedCommand, ServerGoodbyeAction, ServerHelloCommand, ServerMalformedAction, ServerOKCommand, ServerRefreshCommand, ServerSocketStartupAction, SocketState, SyncableAction } from "../../../common/src/actions/ServerAction"; import {CLIENT_GOODBYE, CLIENT_HELLO, SendableAction} from "../../../common/src/actions/ClientAction"; interface StateRecalculationInputs { /** The original server state before the actions changed. The base on which the actions are all applied. */ readonly serverState: SyncedState /** The permanent actions which will be applied to form the new server state. */ readonly permanentActions: readonly SyncableAction[] /** * Actions which have already been sent to the server and therefore can't be deleted, * but have not been approved and can't be applied to the server state. * Applied to the local state first. */ readonly sentActions: readonly SendableAction[] /** * Actions which have not yet been sent to the server and therefore can be deleted. * Applied to the local state second, and failed or no-effect actions are removed from the list returned. */ readonly unsentActions: readonly SendableAction[] } interface StateRecalculationOutputs { /** The new server state after applying permanentActions to oldServerState. */ readonly newServerState: SyncedState, /** The new local state after applying permanentActions, sentActions, and unsentActions (in that order) to oldServerState. */ readonly newLocalState: SyncedState, /** The unsentActions list from the input, minus any actions which failed or had no effect. */ readonly appliedUnsentActions: readonly SendableAction[] } function recalculateStates(input: StateRecalculationInputs): StateRecalculationOutputs { const newServerState = applySyncableActions(input.serverState, input.permanentActions).state const {state: sentLocalState} = applySyncableActions(newServerState, input.sentActions) const {state: newLocalState, appliedActions: appliedUnsentActions} = applySyncableActions(sentLocalState, input.unsentActions) return { newServerState, newLocalState, appliedUnsentActions } } function serverHelloReducer(oldState: AppState, action: ServerHelloCommand): AppState { const { newServerState, newLocalState, appliedUnsentActions, } = recalculateStates({ serverState: action.state, permanentActions: [], sentActions: [], unsentActions: oldState.network.pendingActions }) // TODO: The connection state should be AWAITING_HELLO and the special message should be our Hello // TODO: Destroy all pending actions if the server's map has a different XID from ours. return { ...oldState, localState: newLocalState, network: { ...oldState.network, connectionState: ServerConnectionState.CONNECTED, specialMessage: null, serverState: newServerState, pendingActions: appliedUnsentActions, reconnectAttempts: 0, } } } function serverRefreshReducer(oldState: AppState, action: ServerRefreshCommand): AppState { const { newServerState, newLocalState, appliedUnsentActions, } = recalculateStates({ serverState: action.state, permanentActions: [], sentActions: oldState.network.sentActions.map((sent) => sent.action), unsentActions: oldState.network.pendingActions }) // TODO: The connection state should be AWAITING_REFRESH and the special message should be our Refresh // TODO: Destroy all pending actions if the server's map has a different XID from ours. return { ...oldState, localState: newLocalState, network: { ...oldState.network, connectionState: ServerConnectionState.CONNECTED, specialMessage: null, serverState: newServerState, pendingActions: appliedUnsentActions, } }; } function serverGoodbyeReducer(oldState: NetworkState, action: ServerGoodbyeAction): NetworkState { // TODO: Sort out the correct state and autoReconnectAt based on the time in the action. return { ...oldState, connectionState: ServerConnectionState.OFFLINE, specialMessage: null, goodbyeCode: action.code, goodbyeReason: action.reason, } } function serverOkReducer(oldState: AppState, action: ServerOKCommand): AppState { if (oldState.network.serverState === null) { return oldState } const okIndexes: {[id: number]: boolean|undefined} = {} for (let index = 0; index < action.ids.length; index += 1) { okIndexes[action.ids[index]] = true } // TODO: Technically, the server is not obligated to apply the actions in order, so this should reconstruct the // array the way the server put it. In practice, this is how the server behaves, so it does no harm. const receivedActions = oldState.network.sentActions.filter((sent) => !!okIndexes[sent.id]).map((sent) => sent.action) const stillWaitingActions = oldState.network.sentActions.filter((sent) => !okIndexes[sent.id]) const {newServerState, newLocalState, appliedUnsentActions} = recalculateStates({ serverState: oldState.network.serverState, permanentActions: receivedActions, sentActions: stillWaitingActions.map((sent) => sent.action), unsentActions: oldState.network.pendingActions, }) return { ...oldState, localState: newLocalState, network: { ...oldState.network, serverState: newServerState, sentActions: stillWaitingActions, pendingActions: appliedUnsentActions, } } } function serverFailedReducer(oldState: AppState, action: ServerFailedCommand): AppState { if (oldState.network.serverState === null) { return oldState } const failedIndexes: {[id: number]: boolean|undefined} = {} for (let index = 0; index < action.ids.length; index += 1) { failedIndexes[action.ids[index]] = true } // TODO: Figure out somewhere to put the failure message for logging purposes, so the messages aren't wasted. /* const failedActions = */ oldState.network.sentActions.filter((sent) => !!failedIndexes[sent.id]).map((sent) => sent.action) const stillWaitingActions = oldState.network.sentActions.filter((sent) => !failedIndexes[sent.id]) const {newServerState, newLocalState, appliedUnsentActions} = recalculateStates({ serverState: oldState.network.serverState, permanentActions: [], sentActions: stillWaitingActions.map((sent) => sent.action), unsentActions: oldState.network.pendingActions, }) return { ...oldState, localState: newLocalState, network: { ...oldState.network, serverState: newServerState, sentActions: stillWaitingActions, pendingActions: appliedUnsentActions, } } } function serverSocketStartupReducer(oldState: NetworkState, action: ServerSocketStartupAction): NetworkState { switch (action.state) { case SocketState.OPEN: return clientReducer(oldState, { type: CLIENT_HELLO, version: 1, }) case SocketState.CONNECTING: return { ...oldState, connectionState: ServerConnectionState.CONNECTING, serverState: null, specialMessage: null, goodbyeCode: null, goodbyeReason: null, autoReconnectAt: null, nextID: 0, sentActions: [], pendingActions: [ ...oldState.sentActions.map((wrapper) => (wrapper.action)), ...oldState.pendingActions, ], // Don't clear reconnectAttempts until SERVER_HELLO. Even if we fail _after_ establishing // a connection, we still want to keep using the longer exponential backoff state, or even give up. } } } function serverActReducer(oldState: AppState, action: ServerActCommand): AppState { if (oldState.network.serverState === null) { return oldState } const { newServerState, newLocalState, appliedUnsentActions, } = recalculateStates({ serverState: oldState.network.serverState, permanentActions: action.actions, sentActions: oldState.network.sentActions.map((sent) => sent.action), unsentActions: oldState.network.pendingActions }) return { ...oldState, localState: newLocalState, network: { ...oldState.network, connectionState: ServerConnectionState.CONNECTED, serverState: newServerState, pendingActions: appliedUnsentActions, } }; } function serverMalformedReducer(oldState: NetworkState, action: ServerMalformedAction): NetworkState { console.log("Got serverMalformed", action.error) return clientReducer(oldState, { type: CLIENT_GOODBYE, code: 1002, // protocol error reason: action.error?.message || "Unknown error" }); } export function serverReducer(oldState: AppState, action: ServerAction): AppState { // TODO: Verify that these messages are only received at the proper times and in the proper states. // e.g., BeginConnecting should only happen when the state is somewhere in the disconnected region. // Goodbye, OK, Failed, and Sent, on the other hand, _can't_ happen then. // Hello and Refresh should only happen when we are explicitly waiting for them. switch (action.type) { case SERVER_HELLO: return serverHelloReducer(oldState, action); case SERVER_REFRESH: return serverRefreshReducer(oldState, action); case SERVER_GOODBYE: return { ...oldState, network: serverGoodbyeReducer(oldState.network, action) }; case SERVER_OK: return serverOkReducer(oldState, action); case SERVER_FAILED: return serverFailedReducer(oldState, action); case SERVER_SOCKET_STARTUP: return { ...oldState, network: serverSocketStartupReducer(oldState.network, action) } case SERVER_ACT: return serverActReducer(oldState, action) case SERVER_MALFORMED: return { ...oldState, network: serverMalformedReducer(oldState.network, action) } } }