diff --git a/apps/movex-docs/pages/changelog.md b/apps/movex-docs/pages/changelog.md index 52fbbb24..c488b47a 100644 --- a/apps/movex-docs/pages/changelog.md +++ b/apps/movex-docs/pages/changelog.md @@ -6,6 +6,7 @@ - [ ] Improve the Connection Events - [ ] Master Actions. See the [Pull Request](https://github.com/movesthatmatter/movex/pull/216) - [ ] State Transformers. See the [Pull Request](https://github.com/movesthatmatter/movex/pull/218) +- [ ] State Transformers applied locally. See the [Pull Request](https://github.com/movesthatmatter/movex/pull/221) ### Breaking diff --git a/libs/movex-core-util/package.json b/libs/movex-core-util/package.json index 301bf117..4f5af8e7 100644 --- a/libs/movex-core-util/package.json +++ b/libs/movex-core-util/package.json @@ -1,6 +1,6 @@ { "name": "movex-core-util", - "version": "0.1.6-45", + "version": "0.1.6-58", "description": "Movex Core Util is the library of utilities for Movex", "license": "MIT", "author": { diff --git a/libs/movex-core-util/src/index.ts b/libs/movex-core-util/src/index.ts index fb2882fd..e027460a 100644 --- a/libs/movex-core-util/src/index.ts +++ b/libs/movex-core-util/src/index.ts @@ -8,4 +8,4 @@ export * from './lib/reducer'; export * from './lib/core-types'; export * from './lib/public-types'; export * from './lib/checkedState'; -export * from './lib/masterQueries'; +export * from './lib/masterContext'; diff --git a/libs/movex-core-util/src/lib/EventEmitter/IOEvents.ts b/libs/movex-core-util/src/lib/EventEmitter/IOEvents.ts index aa84b3f2..2cdc176e 100644 --- a/libs/movex-core-util/src/lib/EventEmitter/IOEvents.ts +++ b/libs/movex-core-util/src/lib/EventEmitter/IOEvents.ts @@ -12,8 +12,9 @@ import type { MovexClient, ResourceIdentifier, SanitizedMovexClient, - MovexClientMasterClockOffset, } from '../core-types'; +import { MovexMasterContext } from '../masterContext'; +// import { MovexMasterContext } from '../reducer'; export type IOEvents< TState = unknown, @@ -72,18 +73,22 @@ export type IOEvents< rid: ResourceIdentifier; action: ActionOrActionTupleFromAction; }) => IOPayloadResult< - | { - type: 'ack'; - nextChecksum: Checksum; - } - | { - type: 'masterActionAck'; - // nextChecksum: Checksum; - nextCheckedAction: ToCheckedAction; - } - | ({ - type: 'reconciliation'; - } & CheckedReconciliatoryActions), + ( + | { + type: 'ack'; + nextChecksum: Checksum; + } + | { + type: 'masterActionAck'; + // nextChecksum: Checksum; + nextCheckedAction: ToCheckedAction; + } + | ({ + type: 'reconciliation'; + } & CheckedReconciliatoryActions) + ) & { + masterContext: MovexMasterContext; + }, 'MasterResourceInexistent' | string >; // Type the other errors @@ -92,21 +97,25 @@ export type IOEvents< * */ onReady: (p: SanitizedMovexClient) => void; - onClockSync: (p: undefined) => IOPayloadResult; // acknowledges the client timestamp + // onClockSync: (p: undefined) => IOPayloadResult; // acknowledges the client timestamp onFwdAction: ( payload: { rid: ResourceIdentifier; + masterContext: MovexMasterContext; } & ToCheckedAction ) => IOPayloadResult; onReconciliateActions: ( payload: { rid: ResourceIdentifier; + masterContext: MovexMasterContext; } & CheckedReconciliatoryActions ) => IOPayloadResult; onResourceSubscriberAdded: (p: { rid: ResourceIdentifier; client: SanitizedMovexClient; + // TODO: Make required after it works + masterContext: MovexMasterContext; // clientId: MovexClient['id']; }) => IOPayloadResult< void, diff --git a/libs/movex-core-util/src/lib/core-types.ts b/libs/movex-core-util/src/lib/core-types.ts index 289425c0..7a243671 100644 --- a/libs/movex-core-util/src/lib/core-types.ts +++ b/libs/movex-core-util/src/lib/core-types.ts @@ -237,16 +237,8 @@ export type MovexClient = { >; }; -export type MovexClientMasterClockOffset = number; - export type SanitizedMovexClient = Pick, 'id' | 'info'> & { - /** - * This is the diff between client and master needed to be adjusted on the client side - * - * TODO: Still not sure it should be available here - meaning all the peers can read it! - */ - clockOffset: MovexClientMasterClockOffset; }; export type ResourceIdentifierObj = { diff --git a/libs/movex-core-util/src/lib/masterContext.ts b/libs/movex-core-util/src/lib/masterContext.ts new file mode 100644 index 00000000..7c60e191 --- /dev/null +++ b/libs/movex-core-util/src/lib/masterContext.ts @@ -0,0 +1,20 @@ +export enum MovexMasterContextMap { + requestAt = '$rqstAt$', + // Can add more such as "lag" +} + +export type MovexMasterContext = { + requestAt: number; // timestamp +}; + +export type MovexMasterContextAsQuery = { + requestAt: () => number; +}; + +export const masterContextQuery: MovexMasterContextAsQuery = { + requestAt: () => MovexMasterContextMap['requestAt'] as unknown as number, +}; + +export const localMasterContextQuery: MovexMasterContextAsQuery = { + requestAt: () => new Date().getTime(), +}; diff --git a/libs/movex-core-util/src/lib/masterQueries.ts b/libs/movex-core-util/src/lib/masterQueries.ts deleted file mode 100644 index a20985a6..00000000 --- a/libs/movex-core-util/src/lib/masterQueries.ts +++ /dev/null @@ -1,17 +0,0 @@ -export enum MasterQueries { - now = '$mvx:NOW()', - // 'lag' = '$LOG', -} - -export type MovexMasterQueries = { - now: () => number; - // lag: () => number; -}; - -export const masterMovexQueries = { - now: () => MasterQueries['now'] as unknown as number, -}; - -export const localMovexQueries = { - now: () => new Date().getTime(), -}; diff --git a/libs/movex-core-util/src/lib/public-types.ts b/libs/movex-core-util/src/lib/public-types.ts index 4638c103..5f1f61c4 100644 --- a/libs/movex-core-util/src/lib/public-types.ts +++ b/libs/movex-core-util/src/lib/public-types.ts @@ -30,6 +30,7 @@ export type MovexLogger = { }; + export type MovexDispatchOf = ( action: ToPublicAction // TODO: Should this be ToPublic?? ) => void; diff --git a/libs/movex-core-util/src/lib/reducer.ts b/libs/movex-core-util/src/lib/reducer.ts index b44cf3ee..818668b6 100644 --- a/libs/movex-core-util/src/lib/reducer.ts +++ b/libs/movex-core-util/src/lib/reducer.ts @@ -6,6 +6,7 @@ import type { ActionsCollectionMapBase, AnyAction, } from './action'; +import { MovexMasterContext } from './masterContext'; export type MovexReducerFromActionsMap< TState, @@ -32,11 +33,11 @@ export type MovexReducerMap< // TAction extends AnyAction = AnyAction // > = (state: TState, action: TAction) => TState; -export type MovexMasterContext = { - // @Deprecate in favor of requestAt Props which enables purity - now: () => number; // timestamp - requestAt: number; // timestamp -}; +// export type MovexMasterContext = { +// // @Deprecate in favor of requestAt Props which enables purity +// // now: () => number; // timestamp +// requestAt: number; // timestamp +// }; export type MovexReducer = (( state: S, diff --git a/libs/movex-master/package.json b/libs/movex-master/package.json index 4e3540f9..d2f28ff6 100644 --- a/libs/movex-master/package.json +++ b/libs/movex-master/package.json @@ -1,6 +1,6 @@ { "name": "movex-master", - "version": "0.1.6-45", + "version": "0.1.6-58", "license": "MIT", "description": "Movex-master defines the master that wil be used on movex-server and movex-react-local-master", "author": { diff --git a/libs/movex-master/src/lib/ConnectionToClient.ts b/libs/movex-master/src/lib/ConnectionToClient.ts index 9168a4b1..fb7b151b 100644 --- a/libs/movex-master/src/lib/ConnectionToClient.ts +++ b/libs/movex-master/src/lib/ConnectionToClient.ts @@ -12,54 +12,12 @@ export class ConnectionToClient< TResourceType extends string, TClientInfo extends MovexClientInfo > { - // public latencyMs: number = 0; - - // public clientClockOffset: number = 0; - constructor( public emitter: EventEmitter>, public client: SanitizedMovexClient ) {} - async setReady() { - await this.syncClocks(); - + setReady() { this.emitter.emit('onReady', this.client); } - - async syncClocks() { - const requestAt = new Date().getTime(); - - // console.log('Sync clock', this.client.id, { requestAt }); - - return this.emitter - .emitAndAcknowledge('onClockSync', undefined) - .then((res) => { - if (res.err) { - // console.log('Sync clock err', this.client.id); - console.error(res.err); - return; - } - - // TODO: This might not be correct - also not sure if this - // it is roughly based on the NTP protocol as described here https://stackoverflow.com/a/15785110/2093626 - // but adjusted for movex - the math might be wrong - // this.latencyMs = requestTime / 2; - - const responseAt = new Date().getTime(); - const requestTime = responseAt - requestAt; - const clientTimeAtRequest = res.val; - - this.client.clockOffset = - clientTimeAtRequest - new Date().getTime() - requestTime; - - // console.log('Sync clock ok', this.client.id, { - // requestAt, - // responseAt, - // requestTime, - // clientTimeAtRequest, - // clientClockOffset: this.client.clockOffset, - // }); - }); - } } diff --git a/libs/movex-master/src/lib/MovexMasterResource.spec.ts b/libs/movex-master/src/lib/MovexMasterResource.spec.ts index 661f2579..a64a0910 100644 --- a/libs/movex-master/src/lib/MovexMasterResource.spec.ts +++ b/libs/movex-master/src/lib/MovexMasterResource.spec.ts @@ -59,7 +59,7 @@ test('applies public action', async () => { }; const actual = await master - .applyAction(rid, clientAId, action, mockMasterContext) + .applyActionAndStateTransformer(rid, clientAId, action, mockMasterContext) .resolveUnwrap(); const actualPublic = await master @@ -114,7 +114,7 @@ test('applies only one private action w/o getting to reconciliation', async () = }; const actual = await master - .applyAction( + .applyActionAndStateTransformer( rid, senderClientId, [privateAction, publicAction], @@ -197,7 +197,7 @@ test('applies private action UNTIL Reconciliation', async () => { // White Private Action const actualActionResultBeforeReconciliation = await master - .applyAction( + .applyActionAndStateTransformer( rid, whitePlayer, [privateWhiteAction, publicWhiteAction], @@ -285,7 +285,7 @@ test('applies private action UNTIL Reconciliation', async () => { // Black Private Action (also the Reconciliatory Action) const actualActionResultAfterReconciliation = await master - .applyAction( + .applyActionAndStateTransformer( rid, blackPlayer, [privateBlackAction, publicBlackAction], diff --git a/libs/movex-master/src/lib/MovexMasterResource.stateTransformers.spec.ts b/libs/movex-master/src/lib/MovexMasterResource.stateTransformers.spec.ts index 92615e43..e539ff86 100644 --- a/libs/movex-master/src/lib/MovexMasterResource.stateTransformers.spec.ts +++ b/libs/movex-master/src/lib/MovexMasterResource.stateTransformers.spec.ts @@ -85,7 +85,7 @@ test('gets initial state transformed with PrevState and Movex Context', async () prev: CounterState, context: MovexMasterContext ): CounterState => { - return { count: context.now() }; + return { count: context.requestAt }; }; const master = new MovexMasterResource( @@ -108,7 +108,7 @@ test('gets initial state transformed with PrevState and Movex Context', async () MockDate.reset(); - const expected = computeCheckedState({ count: MOCKED_NOW }); + const expected = computeCheckedState({ count: 123 }); expect(actualPublic).toEqual(expected); expect(actualClientSpecific).toEqual(expected); diff --git a/libs/movex-master/src/lib/MovexMasterResource.ts b/libs/movex-master/src/lib/MovexMasterResource.ts index bb712d78..fbed31c4 100644 --- a/libs/movex-master/src/lib/MovexMasterResource.ts +++ b/libs/movex-master/src/lib/MovexMasterResource.ts @@ -63,16 +63,6 @@ export class MovexMasterResource< masterContext: MovexMasterContext ): CheckedState { if (typeof this.reducer.$transformState === 'function') { - // const masterContext: MovexMasterContext = { - // now: () => new Date().getTime(), // Should the context just be defined here?, - // requestAt: - // }; - - // console.log( - // '[Movex-master] applyStateTransformer', - // JSON.stringify({ masterContext }) - // ); - return computeCheckedState( this.reducer.$transformState(checkedState[0], masterContext) ); @@ -222,7 +212,7 @@ export class MovexMasterResource< // unknown // >; - applyAction( + applyActionAndStateTransformer( rid: ResourceIdentifier, clientId: MovexClient['id'], actionOrActionTuple: ActionOrActionTupleFromAction, @@ -249,223 +239,235 @@ export class MovexMasterResource< peerActions: PeerActions; }; - return this.getClientSpecificResource(rid, clientId, masterContext).flatMap< - ResponsePayload, - unknown - >((resource) => { - const prevState = resource.state[0]; - - if (isAction(actionOrActionTuple)) { - const publicAction = invoke(() => { - if (isMasterAction(actionOrActionTuple)) { - return { - wasMasterAction: true, - action: parseMasterAction( - actionOrActionTuple as GenericMasterAction - ) as TAction, - }; - } - - return { - action: actionOrActionTuple, - }; - }); - - return this.store - .updateState(rid, this.reducer(prevState, publicAction.action)) - .map( - (nextPublicState): ResponsePayload => ({ - nextPublic: { - checksum: nextPublicState.state[1], - action: publicAction.action, - wasMasterAction: publicAction.wasMasterAction, - }, - peerActions: { - type: 'forwardable', - byClientId: objectKeys(resource.subscribers).reduce( - (prev, nextClientId) => { - // Exclude the sender - if (nextClientId === clientId) { - return prev; - } - - return { - ...prev, - [nextClientId]: { - checksum: nextPublicState.state[1], - action: publicAction.action, + return this.getStoreItem(rid).flatMap( + (resource) => { + const prevState = resource.state[0]; + + // Public Action Path + if (isAction(actionOrActionTuple)) { + const publicAction = invoke(() => { + if (isMasterAction(actionOrActionTuple)) { + return { + wasMasterAction: true, + action: parseMasterAction( + actionOrActionTuple as GenericMasterAction, + masterContext + ) as TAction, + }; + } + + return { action: actionOrActionTuple }; + }); + + // console.log('[applyActionAndStateTransformer]', {rid, clientId, actionOrActionTuple, masterContext}) + + return ( + this.store + .updateState(rid, this.reducer(prevState, publicAction.action)) + // Apply the state transformer. TODO: why wanst this there from the getgo? + .map((item) => ({ + ...item, + state: this.applyStateTransformer(item.state, masterContext), + })) + .map((nextPublicState): ResponsePayload => { + return { + nextPublic: { + checksum: nextPublicState.state[1], + action: publicAction.action, + wasMasterAction: publicAction.wasMasterAction, + }, + peerActions: { + type: 'forwardable', + byClientId: objectKeys(resource.subscribers).reduce( + (prev, nextClientId) => { + // Exclude the sender + if (nextClientId === clientId) { + return prev; + } + + return { + ...prev, + [nextClientId]: { + checksum: nextPublicState.state[1], + action: publicAction.action, + }, + }; }, - }; + {} as ForwardablePeerActions['byClientId'] + ), }, - {} as ForwardablePeerActions['byClientId'] - ), - }, - }) + }; + }) ); - } + } + + const [privateAction, publicAction] = actionOrActionTuple; + const nextPrivateState = this.reducer(prevState, privateAction); + const privatePatch = getMovexStatePatch(prevState, nextPrivateState); + + // TODO: August 30 - Test that the $stateTransfomer gets applied correctly to private actions as well + return ( + this.store + // Apply the Private Action + .addPrivatePatch(rid, clientId, { + action: privateAction, + patch: privatePatch, + }) + + .flatMap((itemWithLatestPatch) => + AsyncResult.all( + new AsyncOk(itemWithLatestPatch), - const [privateAction, publicAction] = actionOrActionTuple; - const nextPrivateState = this.reducer(prevState, privateAction); - const privatePatch = getMovexStatePatch(prevState, nextPrivateState); - - return ( - this.store - // Apply the Private Action - .addPrivatePatch(rid, clientId, { - action: privateAction, - patch: privatePatch, - }) - .flatMap((itemWithLatestPatch) => - AsyncResult.all( - new AsyncOk(itemWithLatestPatch), - - this.getClientSpecificState(rid, clientId, masterContext), - - // Apply the Public Action - // *Note The Public Action needs to get applied after the private one! - // otherwise the resulted private patch will be based off of the next public state - // instead of the prev (private) one. - this.store - .updateState(rid, this.reducer(prevState, publicAction)) - .map((s) => s.state) + this.getClientSpecificState(rid, clientId, masterContext), + + // Apply the Public Action + // *Note The Public Action needs to get applied after the private one! + // otherwise the resulted private patch will be based off of the next public state + // instead of the prev (private) one. + this.store + .updateState(rid, this.reducer(prevState, publicAction)) + .map((s) => s.state) + ) ) - ) - .flatMap(([nextItem, nextPrivateState, nextPublicState]) => - AsyncResult.all( - new AsyncOk(nextItem), - new AsyncOk(nextPrivateState), - new AsyncOk(nextPublicState), - - // Need to get this after the public state updates - this.getStateBySubscriberId(rid, masterContext) + .flatMap(([nextItem, nextPrivateState, nextPublicState]) => + AsyncResult.all( + new AsyncOk(nextItem), + new AsyncOk(nextPrivateState), + new AsyncOk(nextPublicState), + + // Need to get this after the public state updates + this.getStateBySubscriberId(rid, masterContext) + ) ) - ) - // Reconciliation Step - .flatMap( - ([ - nextItem, - nextPrivateState, - nextPublicState, - stateBySubscribersId, - ]) => { - if (this.reducer.$canReconcileState?.(nextPublicState[0])) { - const prevPatchesByClientId = nextItem.patches || {}; - - const allPatches = Object.values(prevPatchesByClientId).reduce( - (prev, next) => [...prev, ...next], - [] as MovexStatePatch[] - ); - - // Merge all the private patches into the public state - const mergedState = this.mergeStatePatches( - nextPublicState[0], - allPatches - ); - - // Run it once more through the reducer with the given private action - // In order to calculate any derived state. If no state get calculated in - // this step, in theory it just returns the prev, but in some cases - // when a different field (such as "isWinner" or "status"), needs to get computed - // based on the fields modified by the private action is when it's needed! - const reconciledState = this.reducer( - mergedState, - privateAction - ); - - return this.store - .update(rid, { - state: computeCheckedState(reconciledState), - // Clear the patches from the Item - patches: undefined, - }) - .map((nextReconciledItemFromPublicState) => { - const checkedReconciliatoryActionsByClientId = objectKeys( - prevPatchesByClientId - ).reduce((accum, nextClientId) => { - const { - [nextClientId]: _, - ...peersPrevPatchesByClientId - } = prevPatchesByClientId; - - const allPeersPatchesAsList = objectKeys( - peersPrevPatchesByClientId - ).reduce((prev, nextPeerId) => { - return [ - ...prev, - ...peersPrevPatchesByClientId[nextPeerId].map( - (p) => - ({ - ...p.action, - isPrivate: undefined, // make the action public - } as ToPublicAction) - ), - ]; - }, [] as ToPublicAction[]); + // Reconciliation Step + .flatMap( + ([ + nextItem, + nextPrivateState, + nextPublicState, + stateBySubscribersId, + ]) => { + if (this.reducer.$canReconcileState?.(nextPublicState[0])) { + const prevPatchesByClientId = nextItem.patches || {}; + + const allPatches = Object.values( + prevPatchesByClientId + ).reduce( + (prev, next) => [...prev, ...next], + [] as MovexStatePatch[] + ); + + // Merge all the private patches into the public state + const mergedState = this.mergeStatePatches( + nextPublicState[0], + allPatches + ); + + // Run it once more through the reducer with the given private action + // In order to calculate any derived state. If no state get calculated in + // this step, in theory it just returns the prev, but in some cases + // when a different field (such as "isWinner" or "status"), needs to get computed + // based on the fields modified by the private action is when it's needed! + const reconciledState = this.reducer( + mergedState, + privateAction + ); + + return this.store + .update(rid, { + state: computeCheckedState(reconciledState), + // Clear the patches from the Item + patches: undefined, + }) + .map((nextReconciledItemFromPublicState) => { + const checkedReconciliatoryActionsByClientId = objectKeys( + prevPatchesByClientId + ).reduce((accum, nextClientId) => { + const { + [nextClientId]: _, + ...peersPrevPatchesByClientId + } = prevPatchesByClientId; + + const allPeersPatchesAsList = objectKeys( + peersPrevPatchesByClientId + ).reduce((prev, nextPeerId) => { + return [ + ...prev, + ...peersPrevPatchesByClientId[nextPeerId].map( + (p) => + ({ + ...p.action, + isPrivate: undefined, // make the action public + } as ToPublicAction) + ), + ]; + }, [] as ToPublicAction[]); + + return { + ...accum, + [nextClientId]: { + actions: allPeersPatchesAsList, + finalChecksum: + nextReconciledItemFromPublicState.state[1], + // finalState: nextReconciledItemFromPublicState.state[0], + }, + }; + }, {} as Record>); return { - ...accum, - [nextClientId]: { - actions: allPeersPatchesAsList, - finalChecksum: - nextReconciledItemFromPublicState.state[1], - // finalState: nextReconciledItemFromPublicState.state[0], + nextPublic: { + checksum: nextReconciledItemFromPublicState.state[1], + action: publicAction, }, - }; - }, {} as Record>); - - return { - nextPublic: { - checksum: nextReconciledItemFromPublicState.state[1], - action: publicAction, - }, - nextPrivate: { - checksum: nextReconciledItemFromPublicState.state[1], - action: privateAction, - }, - peerActions: { - type: 'reconcilable', - byClientId: checkedReconciliatoryActionsByClientId, - } as ReconcilablePeerActions, - } satisfies ResponsePayload; - }); - } - - const nexForwardableActionsByClientId = objectKeys( - stateBySubscribersId - ).reduce((prev, nextClientId) => { - // Exclude the sender - if (nextClientId === clientId) { - return prev; + nextPrivate: { + checksum: nextReconciledItemFromPublicState.state[1], + action: privateAction, + }, + peerActions: { + type: 'reconcilable', + byClientId: checkedReconciliatoryActionsByClientId, + } as ReconcilablePeerActions, + } satisfies ResponsePayload; + }); } - return { - ...prev, - [nextClientId]: { + const nexForwardableActionsByClientId = objectKeys( + stateBySubscribersId + ).reduce((prev, nextClientId) => { + // Exclude the sender + if (nextClientId === clientId) { + return prev; + } + + return { + ...prev, + [nextClientId]: { + action: publicAction, + checksum: stateBySubscribersId[nextClientId][1], + _state: stateBySubscribersId[nextClientId][0], + }, + }; + }, {} as ForwardablePeerActions['byClientId']); + + return new AsyncOk({ + nextPublic: { + checksum: nextPublicState[1], action: publicAction, - checksum: stateBySubscribersId[nextClientId][1], - _state: stateBySubscribersId[nextClientId][0], }, - }; - }, {} as ForwardablePeerActions['byClientId']); - - return new AsyncOk({ - nextPublic: { - checksum: nextPublicState[1], - action: publicAction, - }, - nextPrivate: { - checksum: nextPrivateState[1], - action: privateAction, - }, - peerActions: { - type: 'forwardable', - byClientId: nexForwardableActionsByClientId, - }, - } as const); - } - ) - ); - }); + nextPrivate: { + checksum: nextPrivateState[1], + action: privateAction, + }, + peerActions: { + type: 'forwardable', + byClientId: nexForwardableActionsByClientId, + }, + } as const); + } + ) + ); + } + ); } // TODO: The ResourceType could be generic, or given in the Class Generic diff --git a/libs/movex-master/src/lib/MovexMasterServer.ts b/libs/movex-master/src/lib/MovexMasterServer.ts index 7541ea44..2df6298e 100644 --- a/libs/movex-master/src/lib/MovexMasterServer.ts +++ b/libs/movex-master/src/lib/MovexMasterServer.ts @@ -98,16 +98,15 @@ export class MovexMasterServer { return acknowledge?.(new Err('MasterResourceInexistent')); } - const masterContext = createMasterContext({ - extra: { - clientId: clientConnection.client.id, - req: 'onEmitAction', - action: payload.action, - }, - }); + const masterContext = createMasterContext(); masterResource - .applyAction(rid, clientConnection.client.id, action, masterContext) + .applyActionAndStateTransformer( + rid, + clientConnection.client.id, + action, + masterContext + ) .map(({ nextPublic, nextPrivate, peerActions }) => { if (peerActions.type === 'reconcilable') { // TODO: Filter out the client id so it only received the ack @@ -122,6 +121,7 @@ export class MovexMasterServer { peerConnection.emitter.emit('onReconciliateActions', { rid, ...peerActions.byClientId[peerId], + masterContext, }); return; @@ -132,6 +132,7 @@ export class MovexMasterServer { new Ok({ type: 'reconciliation', ...peerActions.byClientId[clientConnection.client.id], + masterContext, } as const) ); } @@ -156,6 +157,7 @@ export class MovexMasterServer { peerConnection.emitter.emit('onFwdAction', { rid, ...peerActions.byClientId[peerId], + masterContext, }); }); @@ -172,8 +174,9 @@ export class MovexMasterServer { nextCheckedAction: objectOmit(nextPublic, [ 'wasMasterAction', ]), + masterContext, } as const) - : ({ type: 'ack', nextChecksum } as const) + : ({ type: 'ack', nextChecksum, masterContext } as const) ) ); }) @@ -186,12 +189,7 @@ export class MovexMasterServer { p: ReturnType['getResource']> ) => void ) => { - const masterContext = createMasterContext({ - extra: { - clientId: clientConnection.client.id, - req: 'onGetResourceHandler', - }, - }); + const masterContext = createMasterContext(); this.getSanitizedClientSpecificResource( rid, @@ -227,12 +225,7 @@ export class MovexMasterServer { return acknowledge?.(new Err('MasterResourceInexistent')); } - const masterContext = createMasterContext({ - extra: { - clientId: clientConnection.client.id, - req: 'onGetResourceStateHandler', - }, - }); + const masterContext = createMasterContext(); masterResource .getClientSpecificState(rid, clientConnection.client.id, masterContext) @@ -277,12 +270,7 @@ export class MovexMasterServer { return acknowledge?.(new Err('MasterResourceInexistent')); } - const masterContext = createMasterContext({ - extra: { - clientId: clientConnection.client.id, - req: 'onCreateResourceHandler', - }, - }); + const masterContext = createMasterContext(); masterResource .create(resourceType, resourceState, resourceId) @@ -320,12 +308,7 @@ export class MovexMasterServer { return acknowledge?.(new Err('MasterResourceInexistent')); } - const masterContext = createMasterContext({ - extra: { - clientId: clientConnection.client.id, - req: 'onAddResourceSubscriber', - }, - }); + const masterContext = createMasterContext(); masterResource .addResourceSubscriber(payload.rid, clientConnection.client.id) @@ -371,6 +354,7 @@ export class MovexMasterServer { peerConnection.emitter.emit('onResourceSubscriberAdded', { rid: payload.rid, client: clientConnection.client, // TODO: Ensure this doesn't add more props than needed + masterContext, }); }); }) @@ -457,10 +441,7 @@ export class MovexMasterServer { return masterResource .getClientSpecificResource(rid, client.id, masterContext) .map((r) => - itemToSanitizedClientResource( - this.populateClientInfoToSubscribers(r), - client.clockOffset - ) + itemToSanitizedClientResource(this.populateClientInfoToSubscribers(r)) ); } diff --git a/libs/movex-master/src/lib/init.ts b/libs/movex-master/src/lib/init.ts index 66d11843..0b43de6b 100644 --- a/libs/movex-master/src/lib/init.ts +++ b/libs/movex-master/src/lib/init.ts @@ -18,7 +18,7 @@ export const initMovexMaster = < // Run this only if in node! // if (process?.env) { - globalLogsy.info(`[MovexMaster] v${pkgVersion || 'Client-version'} initiating...`); + console.info(`[MovexMaster] v${pkgVersion || 'Client-version'} initiating...`); // } diff --git a/libs/movex-master/src/lib/util.ts b/libs/movex-master/src/lib/util.ts index 045d5431..9ce4569f 100644 --- a/libs/movex-master/src/lib/util.ts +++ b/libs/movex-master/src/lib/util.ts @@ -9,11 +9,11 @@ import { objectKeys, MovexClient, GenericMasterAction, - MasterQueries, ToPublicAction, MovexMasterContext, SanitizedMovexClient, UnknownRecord, + MovexMasterContextMap, } from 'movex-core-util'; import { MovexStoreItem } from 'movex-store'; @@ -87,8 +87,7 @@ export const itemToSanitizedClientResource = < info: MovexClient['info']; } >; - }, - clockOffset: number + } ): MovexClientResourceShape => ({ rid: toResourceIdentifierStr(item.rid), state: item.state, @@ -98,7 +97,6 @@ export const itemToSanitizedClientResource = < [nextSubId]: { id: nextSubId, info: item.subscribers[nextSubId].info || {}, - clockOffset, }, }), {} as MovexClientResourceShape['subscribers'] @@ -149,16 +147,22 @@ const findAllKeyPathsForVal = (obj: object, val: unknown): string[] => { }; export const parseMasterAction = ( - action: GenericMasterAction + action: GenericMasterAction, + masterContext: MovexMasterContext ): ToPublicAction => { - const allNowPaths = findAllKeyPathsForVal( + const allRquestAtPaths = findAllKeyPathsForVal( { action: { payload: action.payload } }, - MasterQueries.now + MovexMasterContextMap.requestAt ); const { action: nextAction } = applyPatch({ action }, [ - ...allNowPaths.map( - (path) => ({ op: 'replace', path, value: new Date().getTime() } as const) + ...allRquestAtPaths.map( + (path) => + ({ + op: 'replace', + path, + value: masterContext.requestAt, + } as const) ), ])[0].newDocument; @@ -173,9 +177,6 @@ export const createMasterContext = (p?: { requestAt?: number; extra?: UnknownRecord; }): MovexMasterContext => ({ - // @Deprecate in favor of requestAt Props which enables purity - now: () => new Date().getTime(), - requestAt: p?.requestAt || new Date().getTime(), ...(p?.extra && { _extra: p?.extra }), @@ -185,9 +186,8 @@ export const createSanitizedMovexClient = < TInfo extends SanitizedMovexClient['info'] = SanitizedMovexClient['info'] >( id: string, - p?: { info?: TInfo; clockOffset?: SanitizedMovexClient['clockOffset'] } + p?: { info?: TInfo } ): SanitizedMovexClient => ({ id, info: p?.info || {}, - clockOffset: p?.clockOffset || 0, }); diff --git a/libs/movex-master/src/specs/ClientMasterOrchestration/orchestrator.ts b/libs/movex-master/src/specs/ClientMasterOrchestration/orchestrator.ts index 4641146b..52e35f41 100644 --- a/libs/movex-master/src/specs/ClientMasterOrchestration/orchestrator.ts +++ b/libs/movex-master/src/specs/ClientMasterOrchestration/orchestrator.ts @@ -5,13 +5,13 @@ import { MovexReducer, MovexClientInfo, SanitizedMovexClient, - MovexMasterContext, } from 'movex-core-util'; import { Movex, ConnectionToMaster } from 'movex'; import { MovexMasterResource, MovexMasterServer } from 'movex-master'; import { MemoryMovexStore } from 'movex-store'; import { MockConnectionEmitter } from '../../lib/MockConnectionEmitter'; import { ConnectionToClient } from '../../lib/ConnectionToClient'; +import { createMasterContext } from '../../lib/util'; // TODO: This was added on April 16th 2024, when I added the subscribers info (client info) @@ -50,7 +50,6 @@ export const movexClientMasterOrchestrator = < id: clientId, // TODO: If this needs to be given here is where it can be info: {} as TClientInfo, - clockOffset: 0, }; // Would this be the only one for both client and master or seperate? @@ -90,17 +89,18 @@ export const movexClientMasterOrchestrator = < return mockedMovex.movex.register(resourceType, reducer); }); - // TODO: This might need to change according to the needs of the test - const masterContext: MovexMasterContext = { - now: () => new Date().getTime(), - requestAt: new Date().getTime(), + const getMasterPublicState = (rid: ResourceIdentifier) => { + const masterContext = createMasterContext({ + extra: { + _isOrchestrator: true, + }, + }); + + return masterResource.getPublicState(rid, masterContext); }; return { - master: { - getPublicState: (rid: ResourceIdentifier) => - masterResource.getPublicState(rid, masterContext), - }, + master: { getPublicState: getMasterPublicState }, clients, $util: { pauseEmit: () => { @@ -113,6 +113,7 @@ export const movexClientMasterOrchestrator = < clientEmitters.forEach((c) => c._setEmitDelay(ms)); }, clientEmitters, + getMasterPublicState, }, }; }; diff --git a/libs/movex-master/src/specs/Movex.masterActions.spec.ts b/libs/movex-master/src/specs/Movex.masterActions.spec.ts index be156e50..d86f7897 100644 --- a/libs/movex-master/src/specs/Movex.masterActions.spec.ts +++ b/libs/movex-master/src/specs/Movex.masterActions.spec.ts @@ -5,7 +5,10 @@ import { IOEvents, } from 'movex-core-util'; import { counterReducer, tillNextTick } from 'movex-specs-util'; -import { movexClientMasterOrchestrator } from 'movex-master'; +import { + createMasterContext, + movexClientMasterOrchestrator, +} from 'movex-master'; import MockDate from 'mockdate'; import { Ok } from 'ts-results'; @@ -43,12 +46,12 @@ describe('Dispatching a Public Master Action with a Single Client', () => { // Pause the emits to master so I can check the intermediary state on local $util.pauseEmit(); - const MOCKED_NOW = 33; + let MOCKED_NOW = 33; MockDate.set(new Date(MOCKED_NOW)); - r.dispatch((movex) => ({ + r.dispatch((masterContext) => ({ type: 'incrementBy', - payload: movex.$queries.now(), + payload: masterContext.requestAt(), })); await tillNextTick(); @@ -62,15 +65,18 @@ describe('Dispatching a Public Master Action with a Single Client', () => { // Master updates (client-master sync) + MOCKED_NOW += 2; // Mock the different time on the server - MockDate.set(new Date(MOCKED_NOW + 2)); + MockDate.set(new Date(MOCKED_NOW)); // Resume the master emits $util.resumeEmit(); await tillNextTick(); const actual = r.getCheckedState(); - const expected = computeCheckedState({ count: MOCKED_NOW + 2 }); + const expected = computeCheckedState({ count: MOCKED_NOW }); + + const expectedMockState = createMasterContext({ requestAt: MOCKED_NOW }); expect(actual).toEqual(expected); @@ -84,11 +90,12 @@ describe('Dispatching a Public Master Action with a Single Client', () => { type: 'masterActionAck', nextCheckedAction: { action: { - payload: MOCKED_NOW + 2, + payload: MOCKED_NOW, type: 'incrementBy', }, checksum: expected[1], }, + masterContext: expectedMockState, } as const); expect(emitActionDispatchSpy).toHaveBeenCalledWith(expectedAckResponse); @@ -115,7 +122,7 @@ describe('Dispatching a Public Master Action with a Multiple Clients', () => { // Pause the emits to master so I can check the intermediary state on local - const MOCKED_NOW = 33; + let MOCKED_NOW = 33; MockDate.set(new Date(MOCKED_NOW)); const peerOnFwdActionSpy = jest.fn(); @@ -125,9 +132,9 @@ describe('Dispatching a Public Master Action with a Multiple Clients', () => { $util.pauseEmit(); - aClientMovex.dispatch((movex) => ({ + aClientMovex.dispatch((masterContext) => ({ type: 'incrementBy', - payload: movex.$queries.now(), + payload: masterContext.requestAt(), })); await tillNextTick(); @@ -153,6 +160,8 @@ describe('Dispatching a Public Master Action with a Multiple Clients', () => { const actual = aClientMovex.getCheckedState(); const expected = computeCheckedState({ count: MASTER_MOCKED_NOW }); + const expectedMasterContext = createMasterContext({ requestAt: MASTER_MOCKED_NOW }) + expect(actual).toEqual(expected); const expectedPeerFwdActionPayload: Parameters< @@ -168,6 +177,7 @@ describe('Dispatching a Public Master Action with a Multiple Clients', () => { payload: MASTER_MOCKED_NOW, }, checksum: expected[1], + masterContext: expectedMasterContext, }; expect(peerOnFwdActionSpy).toHaveBeenCalledWith( diff --git a/libs/movex-master/src/specs/Movex.stateTransformers.spec.ts b/libs/movex-master/src/specs/Movex.stateTransformers.spec.ts index 8958de9a..564215e4 100644 --- a/libs/movex-master/src/specs/Movex.stateTransformers.spec.ts +++ b/libs/movex-master/src/specs/Movex.stateTransformers.spec.ts @@ -5,7 +5,10 @@ import { speedPushGameReducer, tillNextTick, } from 'movex-specs-util'; -import { createSanitizedMovexClient, movexClientMasterOrchestrator } from 'movex-master'; +import { + createSanitizedMovexClient, + movexClientMasterOrchestrator, +} from 'movex-master'; import MockDate from 'mockdate'; const orchestrator = movexClientMasterOrchestrator(); @@ -14,13 +17,22 @@ beforeEach(async () => { await orchestrator.unsubscribe(); }); +afterEach(() => { + MockDate.reset(); +}); + test('State is changed (to status="completed") when a related ACTION DISPATCH triggers it', async () => { + const REQUEST_AT = 1; + + MockDate.set(REQUEST_AT); + const { clients: [speedGameResource], } = orchestrator.orchestrate({ clientIds: ['test-user'], reducer: speedPushGameReducer, resourceType: 'match', + // masterContextParams: { requestAt: REQUEST_AT }, }); const { rid } = await speedGameResource @@ -29,7 +41,13 @@ test('State is changed (to status="completed") when a related ACTION DISPATCH tr const r = speedGameResource.bind(rid); - const FIRST_PUSH_AT = 1; + const syncStateSpy = jest.spyOn(r, 'syncState'); // jest already called the spyOn once + + await tillNextTick(); + + expect(syncStateSpy).toHaveBeenCalledTimes(1); + + const FIRST_PUSH_AT = REQUEST_AT + 1; r.dispatch({ type: 'push', @@ -41,6 +59,11 @@ test('State is changed (to status="completed") when a related ACTION DISPATCH tr await tillNextTick(); + expect(syncStateSpy).toHaveBeenCalledTimes(1); // still the same number of calls + + // createMasterContext + // how to mock the create + const actualAfterWhiteMove = r.get(); const expectedAfterWhiteMove = { @@ -83,7 +106,8 @@ test('State is changed (to status="completed") when a related ACTION DISPATCH tr expect(actual).toEqual(expected); }); -test('State is changed (to status="completed") when state is READ directly (w/o dispatching an action)', async () => { +// test.only('State is changed (to status="completed") when state is READ directly (w/o dispatching an action)', async () => { +test('State is changed (to status="completed") after the 1st dispatch due to local-run $stateTransform with the movexContext', async () => { const { clients: [speedGameResource], } = orchestrator.orchestrate({ @@ -100,6 +124,12 @@ test('State is changed (to status="completed") when state is READ directly (w/o const FIRST_PUSH_AT = 1; + const syncStateSpy = jest.spyOn(r, 'syncState'); + + await tillNextTick(); + + expect(syncStateSpy).toHaveBeenCalledTimes(1); // jest already called the spyOn once + r.dispatch({ type: 'push', payload: { @@ -113,8 +143,8 @@ test('State is changed (to status="completed") when state is READ directly (w/o const actualAfterWhiteMove = r.getCheckedState(); const expectedAfterWhiteMove = computeCheckedState({ - status: 'ongoing', - winner: undefined, + status: 'completed', + winner: 'red', lastPushAt: FIRST_PUSH_AT, lastPushBy: 'red', timeToNextPushMs: initialSpeedPushGameState.timeToNextPushMs, @@ -128,20 +158,22 @@ test('State is changed (to status="completed") when state is READ directly (w/o const actual = (await speedGameResource.get(rid).resolveUnwrap()).state; - const expected = { - status: 'completed', - winner: 'red', - lastPushAt: FIRST_PUSH_AT, - lastPushBy: 'red', - timeToNextPushMs: initialSpeedPushGameState.timeToNextPushMs, - }; + // The masterState is alredy same as the localState becase the $transformState already got applied locally + const expected = expectedAfterWhiteMove[0]; expect(actual).toEqual(expected); + + expect(syncStateSpy).toHaveBeenCalledTimes(1); // jest already called the spyOn once }); -test('State is changed (to status="completed") when ANY UNRELATED ACTION gets dispatched', async () => { +test('State is changed (to status="completed") when ANY UNRELATED ACTION gets dispatched w/o having to resync (initial checksums mismatch)', async () => { + const REQUEST_AT = 1; + + MockDate.set(REQUEST_AT); + const { clients: [speedGameResource], + $util, } = orchestrator.orchestrate({ clientIds: ['test-user'], reducer: speedPushGameReducer, @@ -154,7 +186,13 @@ test('State is changed (to status="completed") when ANY UNRELATED ACTION gets di const r = speedGameResource.bind(rid); - const FIRST_PUSH_AT = 1; + const FIRST_PUSH_AT = REQUEST_AT + 1; + + const syncStateSpy = jest.spyOn(r, 'syncState'); + + await tillNextTick(); + + expect(syncStateSpy).toHaveBeenCalledTimes(1); // jest already called the spyOn once r.dispatch({ type: 'push', @@ -166,6 +204,8 @@ test('State is changed (to status="completed") when ANY UNRELATED ACTION gets di await tillNextTick(); + // TODO: Left it here - as I don't know what else to do to sync it after the dispatch! + const actualAfterWhiteMove = r.getCheckedState(); const expectedAfterWhiteMove = computeCheckedState({ @@ -186,6 +226,8 @@ test('State is changed (to status="completed") when ANY UNRELATED ACTION gets di await tillNextTick(); + expect(syncStateSpy).toHaveBeenCalledTimes(1); // only the spyOn call + const actual = r.getCheckedState(); const expected = computeCheckedState({ @@ -198,3 +240,71 @@ test('State is changed (to status="completed") when ANY UNRELATED ACTION gets di expect(actual).toEqual(expected); }); + +describe('fwdActions to Peers', () => { + test('action dispatch that triggers $stateTransform forwards correctly to peers without any state resync', async () => { + const { + clients: [aResource, bResource], + } = orchestrator.orchestrate({ + clientIds: ['a', 'b'], + reducer: speedPushGameReducer, + resourceType: 'match', + }); + + const { rid } = await aResource + .create(initialSpeedPushGameState) + .resolveUnwrap(); + + const aMovex = aResource.bind(rid); + const bMovex = bResource.bind(rid); + + const FIRST_PUSH_AT = 1; + + const syncStateSpyA = jest.spyOn(aMovex, 'syncState'); + const syncStateSpyB = jest.spyOn(bMovex, 'syncState'); + + await tillNextTick(); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // jest already called the spyOn once + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // jest already called the spyOn once + + aMovex.dispatch({ + type: 'push', + payload: { + by: 'red', + at: FIRST_PUSH_AT, + }, + }); + + await tillNextTick(); + + const actualAMovexAfterWhiteMove = aMovex.getCheckedState(); + + const actualBMovexAfterWhiteMove = bMovex.getCheckedState(); + + const expectedAfterWhiteMove = computeCheckedState({ + status: 'completed', + winner: 'red', + lastPushAt: FIRST_PUSH_AT, + lastPushBy: 'red', + timeToNextPushMs: initialSpeedPushGameState.timeToNextPushMs, + }); + + expect(actualAMovexAfterWhiteMove).toEqual(expectedAfterWhiteMove); + expect(actualBMovexAfterWhiteMove).toEqual(expectedAfterWhiteMove); + + MockDate.set( + new Date(FIRST_PUSH_AT + initialSpeedPushGameState.timeToNextPushMs + 1) + ); + + const actual = (await aResource.get(rid).resolveUnwrap()).state; + + // The masterState is alredy same as the localState becase the $transformState already got applied locally + const expected = expectedAfterWhiteMove[0]; + + expect(actual).toEqual(expected); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + }); +}); diff --git a/libs/movex-master/src/specs/Movex.stateTransformersAndMasterActions.spec.ts b/libs/movex-master/src/specs/Movex.stateTransformersAndMasterActions.spec.ts new file mode 100644 index 00000000..bded950b --- /dev/null +++ b/libs/movex-master/src/specs/Movex.stateTransformersAndMasterActions.spec.ts @@ -0,0 +1,461 @@ +import { computeCheckedState, invoke } from 'movex-core-util'; +import { + simpleChessGameReducer, + tillNextTick, + initialSimpleChessGameState, + SimpleChessGameState, +} from 'movex-specs-util'; +import { movexClientMasterOrchestrator } from 'movex-master'; +import MockDate from 'mockdate'; + +const orchestrator = movexClientMasterOrchestrator(); + +beforeEach(async () => { + await orchestrator.unsubscribe(); +}); + +afterEach(() => { + MockDate.reset(); +}); + +test('dispatching an action that DOES NOT affect timeLefts YET keeps everything in sync', async () => { + const TOTAL_TIME_LEFT = 30; + + const INITIAL_GAME_STATE: SimpleChessGameState = { + ...initialSimpleChessGameState, + timeLefts: { white: TOTAL_TIME_LEFT, black: TOTAL_TIME_LEFT }, + }; + + const { + clients: [aResource, bResource], + $util, + } = orchestrator.orchestrate({ + clientIds: ['a', 'b'], + reducer: simpleChessGameReducer, + resourceType: 'match', + }); + + const { rid } = await aResource.create(INITIAL_GAME_STATE).resolveUnwrap(); + + const aMovex = aResource.bind(rid); + const bMovex = bResource.bind(rid); + + const syncStateSpyA = jest.spyOn(aMovex, 'syncState'); + const syncStateSpyB = jest.spyOn(bMovex, 'syncState'); + + await tillNextTick(); + + syncStateSpyA.mockImplementation(() => { + console.error('syncStateSpyA should not be called'); + }); + + syncStateSpyB.mockImplementation(() => { + console.error('syncStateSpyB should not be called'); + }); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // jest already called the spyOn once + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // jest already called the spyOn once + + const FIRST_LOCAL_REQUEST_AT = 1; + MockDate.set(FIRST_LOCAL_REQUEST_AT); + + $util.pauseEmit(); + + aMovex.dispatch((masterContext) => ({ + type: 'move', + payload: { + by: 'white', + at: masterContext.requestAt(), + sq: 'e4', + }, + })); + + await tillNextTick(); + + invoke(function checkBeforeMasterEmit() { + const actualForA = aMovex.getCheckedState(); + const actualForB = bMovex.getCheckedState(); + + const expectedForA = computeCheckedState({ + status: 'ongoing', + winner: undefined, + lastMoveAt: FIRST_LOCAL_REQUEST_AT, + lastMoveBy: 'white', + timeLefts: { + white: INITIAL_GAME_STATE.timeLefts.white, + black: INITIAL_GAME_STATE.timeLefts.black, + }, + }); + + const expectedForB = computeCheckedState(INITIAL_GAME_STATE); + + expect(actualForA).toEqual(expectedForA); + expect(actualForB).toEqual(expectedForB); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + }); + + const FIRST_MASTER_REQUEST_AT = 3; + MockDate.set(new Date(FIRST_MASTER_REQUEST_AT)); + + // Resume emits + $util.resumeEmit(); + await tillNextTick(); + + await invoke(async function checkAfterMasterEmit() { + const actualForA = aMovex.getCheckedState(); + const actualForB = bMovex.getCheckedState(); + const actualMaster = await $util.getMasterPublicState(rid).resolveUnwrap(); + + const expectedForA = computeCheckedState({ + status: 'ongoing', + winner: undefined, + lastMoveAt: FIRST_MASTER_REQUEST_AT, + lastMoveBy: 'white', + timeLefts: { + white: INITIAL_GAME_STATE.timeLefts.white, + black: INITIAL_GAME_STATE.timeLefts.black, + }, + }); + + // Check that the 2 peer states are in sync after master emits + const expectedForB = expectedForA; + + const expectedOnMaster = expectedForA; + + expect(actualForA).toEqual(expectedForA); + expect(actualForB).toEqual(expectedForB); + expect(actualMaster).toEqual(expectedOnMaster); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + }); + + await tillNextTick(); + + await invoke(async function checkMasterStateGetOnDemand() { + const TIME_SINCE_LAST_REQUEST = 2; + MockDate.set(FIRST_MASTER_REQUEST_AT + TIME_SINCE_LAST_REQUEST); + + const actual = (await aResource.get(rid).resolveUnwrap()).state; + const actualOrchestratorMaster = ( + await $util.getMasterPublicState(rid).resolveUnwrap() + )[0]; + + // The masterState is alredy same as the localState becase the $transformState already got applied locally + const expected: SimpleChessGameState = { + status: 'ongoing', + winner: undefined, + lastMoveAt: FIRST_MASTER_REQUEST_AT, + lastMoveBy: 'white', + timeLefts: { + white: INITIAL_GAME_STATE.timeLefts.white, + black: INITIAL_GAME_STATE.timeLefts.black - TIME_SINCE_LAST_REQUEST, + }, + }; + + expect(actual).toEqual(expected); + expect(actualOrchestratorMaster).toEqual(expected); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + }); +}); + +test('dispatching an action that affects timeLefts acknowledges them and forwards them correctly (keeping them in sync)', async () => { + const TOTAL_TIME_LEFT = 30; + + const INITIAL_GAME_STATE: SimpleChessGameState = { + ...initialSimpleChessGameState, + timeLefts: { white: TOTAL_TIME_LEFT, black: TOTAL_TIME_LEFT }, + }; + + const { + clients: [aResource, bResource], + $util, + } = orchestrator.orchestrate({ + clientIds: ['a', 'b'], + reducer: simpleChessGameReducer, + resourceType: 'match', + }); + + const { rid } = await aResource.create(INITIAL_GAME_STATE).resolveUnwrap(); + + const aMovex = aResource.bind(rid); + const bMovex = bResource.bind(rid); + + const syncStateSpyA = jest.spyOn(aMovex, 'syncState'); + const syncStateSpyB = jest.spyOn(bMovex, 'syncState'); + + await tillNextTick(); + + syncStateSpyA.mockImplementation(() => { + console.error('syncStateSpyA should not be called'); + }); + + syncStateSpyB.mockImplementation(() => { + console.error('syncStateSpyB should not be called'); + }); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // jest already called the spyOn once + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // jest already called the spyOn once + + const FIRST_LOCAL_REQUEST_AT = 1; + MockDate.set(FIRST_LOCAL_REQUEST_AT); + + // First Request (does not affect time lefts) + aMovex.dispatch((masterContext) => ({ + type: 'move', + payload: { + by: 'white', + at: masterContext.requestAt(), + sq: 'e4', + }, + })); + + await tillNextTick(); + + // Second Request (affects timelefts) + + $util.pauseEmit(); + + const SECOND_LOCAL_REQUEST_AT = 5; + MockDate.set(SECOND_LOCAL_REQUEST_AT); + + aMovex.dispatch((masterContext) => ({ + type: 'move', + payload: { + by: 'black', + at: masterContext.requestAt(), + sq: 'd6', + }, + })); + + invoke(function checkBeforeMasterEmit() { + const actualForA = aMovex.getCheckedState(); + const actualForB = bMovex.getCheckedState(); + + const expectedForA = computeCheckedState({ + status: 'ongoing', + winner: undefined, + lastMoveAt: FIRST_LOCAL_REQUEST_AT, + lastMoveBy: 'white', + timeLefts: { + white: INITIAL_GAME_STATE.timeLefts.white, + black: INITIAL_GAME_STATE.timeLefts.black, + }, + }); + + const expectedForB = expectedForA; + + expect(actualForA).toEqual(expectedForA); + expect(actualForB).toEqual(expectedForB); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + }); + + const SECOND_MASTER_REQUEST_AT = SECOND_LOCAL_REQUEST_AT + 3; + MockDate.set(new Date(SECOND_MASTER_REQUEST_AT)); + + // Resume emits + $util.resumeEmit(); + await tillNextTick(); + + await invoke(async function checkAfterMasterEmit() { + const actualForA = aMovex.getCheckedState(); + const actualForB = bMovex.getCheckedState(); + const actualMaster = await $util.getMasterPublicState(rid).resolveUnwrap(); + + const expectedForA = computeCheckedState({ + status: 'ongoing', + winner: undefined, + lastMoveAt: SECOND_MASTER_REQUEST_AT, + lastMoveBy: 'black', + timeLefts: { + white: INITIAL_GAME_STATE.timeLefts.white, + black: + TOTAL_TIME_LEFT - (SECOND_MASTER_REQUEST_AT - FIRST_LOCAL_REQUEST_AT), + }, + }); + + // Check that the 2 peer states are in sync after master emits + const expectedForB = expectedForA; + + const expectedMaster = expectedForA; + + expect(actualForA).toEqual(expectedForA); + expect(actualForB).toEqual(expectedForB); + expect(actualMaster).toEqual(expectedMaster); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + }); + + await tillNextTick(); + + await invoke( + async function checkMasterStateGetOnDemandChangesTimeLeftsOnRead() { + const TIME_SINCE_LAST_REQUEST = 12; + MockDate.set(SECOND_MASTER_REQUEST_AT + TIME_SINCE_LAST_REQUEST); + + const actual = (await aResource.get(rid).resolveUnwrap()).state; + const actualOrchestratorMaster = ( + await $util.getMasterPublicState(rid).resolveUnwrap() + )[0]; + + // The masterState is already same as the localState becase the $transformState already got applied locally + const expected: SimpleChessGameState = { + status: 'ongoing', + winner: undefined, + lastMoveAt: SECOND_MASTER_REQUEST_AT, + lastMoveBy: 'black', + timeLefts: { + white: INITIAL_GAME_STATE.timeLefts.white - TIME_SINCE_LAST_REQUEST, + black: + TOTAL_TIME_LEFT - + (SECOND_MASTER_REQUEST_AT - FIRST_LOCAL_REQUEST_AT), + }, + }; + + expect(actual).toEqual(expected); + expect(actualOrchestratorMaster).toEqual(expected); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + } + ); + + // await invoke(async function checkMasterStateGetOnDemand() { + // const TIME_SINCE_LAST_REQUEST = 12; + // MockDate.set(SECOND_MASTER_REQUEST_AT + TIME_SINCE_LAST_REQUEST); + + // const actual = (await aResource.get(rid).resolveUnwrap()).state; + + // // The masterState is already same as the localState becase the $transformState already got applied locally + // const expected: SimpleChessGameState = { + // status: 'ongoing', + // winner: undefined, + // lastMoveAt: SECOND_MASTER_REQUEST_AT, + // lastMoveBy: 'black', + // timeLefts: { + // white: INITIAL_GAME_STATE.timeLefts.white - TIME_SINCE_LAST_REQUEST, + // black: + // TOTAL_TIME_LEFT - (SECOND_MASTER_REQUEST_AT - FIRST_LOCAL_REQUEST_AT), + // }, + // }; + + // expect(actual).toEqual(expected); + + // expect(syncStateSpyA).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + // expect(syncStateSpyB).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + // }); +}); + +test('dispatching after a mutating $stateTransformer, still keeps the states in sync correctly', async () => { + MockDate.set(0); + const INITIAL_GAME_STATE: Extract< + SimpleChessGameState, + { status: 'ongoing' } + > = { + status: 'ongoing', + winner: undefined, + lastMoveAt: 12, + lastMoveBy: 'black', + timeLefts: { + white: 23, + black: 34, + }, + }; + + const { + clients: [aResource, bResource], + $util, + } = orchestrator.orchestrate({ + clientIds: ['a', 'b'], + reducer: simpleChessGameReducer, + resourceType: 'match', + }); + + const { rid } = await aResource.create(INITIAL_GAME_STATE).resolveUnwrap(); + + const aMovex = aResource.bind(rid); + const bMovex = bResource.bind(rid); + + const syncStateSpyA = jest.spyOn(aMovex, 'syncState'); + const syncStateSpyB = jest.spyOn(bMovex, 'syncState'); + + await tillNextTick(); + + syncStateSpyA.mockImplementation(() => { + console.error('syncStateSpyA should not be called'); + }); + + syncStateSpyB.mockImplementation(() => { + console.error('syncStateSpyB should not be called'); + }); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // jest already called the spyOn once + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // jest already called the spyOn once + + await invoke(async function checkPlayerAMasterStateTransformations() { + const TIME_SINCE_LAST_REQUEST = 3; + MockDate.set(INITIAL_GAME_STATE.lastMoveAt + TIME_SINCE_LAST_REQUEST); + + const actual = (await aResource.get(rid).resolveUnwrap()).state; + const actualOrchestratorMaster = ( + await $util.getMasterPublicState(rid).resolveUnwrap() + )[0]; + + // The masterState is already same as the localState becase the $transformState already got applied locally + const expected: SimpleChessGameState = { + status: 'ongoing', + winner: undefined, + lastMoveAt: INITIAL_GAME_STATE.lastMoveAt, + lastMoveBy: 'black', + timeLefts: { + white: INITIAL_GAME_STATE.timeLefts.white - TIME_SINCE_LAST_REQUEST, + black: INITIAL_GAME_STATE.timeLefts.black, + }, + }; + + expect(actual).toEqual(expected); + expect(actualOrchestratorMaster).toEqual(expected); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + }); + + await invoke(async function checkPlayerBMasterStateTransformations() { + const TIME_SINCE_LAST_REQUEST = 5; + MockDate.set(INITIAL_GAME_STATE.lastMoveAt + TIME_SINCE_LAST_REQUEST); + + const actual = (await bResource.get(rid).resolveUnwrap()).state; + const actualOrchestratorMaster = ( + await $util.getMasterPublicState(rid).resolveUnwrap() + )[0]; + + // The masterState is already same as the localState becase the $transformState already got applied locally + const expected: SimpleChessGameState = { + status: 'ongoing', + winner: undefined, + lastMoveAt: INITIAL_GAME_STATE.lastMoveAt, + lastMoveBy: 'black', + timeLefts: { + white: INITIAL_GAME_STATE.timeLefts.white - TIME_SINCE_LAST_REQUEST, + black: INITIAL_GAME_STATE.timeLefts.black, + }, + }; + + expect(actual).toEqual(expected); + expect(actualOrchestratorMaster).toEqual(expected); + + expect(syncStateSpyA).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + expect(syncStateSpyB).toHaveBeenCalledTimes(1); // ensure no other state-sync calls happened + }); +}); + +test('requesting the state again (via unrelated action dispatch) will return the updated time lefts', () => {}); + +test('requesting the state again (via GET) will return the updated time lefts', () => {}); diff --git a/libs/movex-react-local-master/src/lib/ClientMasterOrchestrator.ts b/libs/movex-react-local-master/src/lib/ClientMasterOrchestrator.ts index 0e607315..b653d38c 100644 --- a/libs/movex-react-local-master/src/lib/ClientMasterOrchestrator.ts +++ b/libs/movex-react-local-master/src/lib/ClientMasterOrchestrator.ts @@ -41,7 +41,6 @@ export const orchestrateDefinedMovex = < info: { _clientType: 'orchestrator', // TODO: Take this one out }, - clockOffset: 0, }) ), emitter: emitterOnClient, diff --git a/libs/movex-react-local-master/src/lib/MovexLocalProvider.tsx b/libs/movex-react-local-master/src/lib/MovexLocalProvider.tsx index 64025b38..7507fe62 100644 --- a/libs/movex-react-local-master/src/lib/MovexLocalProvider.tsx +++ b/libs/movex-react-local-master/src/lib/MovexLocalProvider.tsx @@ -62,7 +62,6 @@ export class MovexLocalProvider< const clientWithInfo: SanitizedMovexClient<{}> = { id: this.props.clientId || getUuid(), info: {}, - clockOffset: 0, }; // This should be defined as real source not just as a mock diff --git a/libs/movex-react-local-master/src/stories/speedPushGame/Game.tsx b/libs/movex-react-local-master/src/stories/speedPushGame/Game.tsx index a24caab6..54b96a07 100644 --- a/libs/movex-react-local-master/src/stories/speedPushGame/Game.tsx +++ b/libs/movex-react-local-master/src/stories/speedPushGame/Game.tsx @@ -73,9 +73,9 @@ export function Game(props: Props) {