Add client ACK
This commit is contained in:
parent
9fc770d086
commit
e96fbc27ba
@ -1,5 +1,5 @@
|
|||||||
import { ExponentialBackoff, Websocket, WebsocketBuilder } from 'websocket-ts'
|
import { ExponentialBackoff, Websocket, WebsocketBuilder } from 'websocket-ts'
|
||||||
import { ActionClientMessage, ActionServerMessage, ConfirmServerMessage, HandshakeClientMessage, HandshakeServerMessage, ServerMessage, KetchupSynced } from 'ketchup-common'
|
import { ActionClientMessage, ActionServerMessage, ConfirmServerMessage, HandshakeClientMessage, HandshakeServerMessage, ServerMessage, KetchupSynced, HeartbeatClientMessage } from 'ketchup-common'
|
||||||
import EventEmitter from 'events'
|
import EventEmitter from 'events'
|
||||||
|
|
||||||
export default class KetchupClient<State, Action> extends EventEmitter {
|
export default class KetchupClient<State, Action> extends EventEmitter {
|
||||||
@ -7,11 +7,13 @@ export default class KetchupClient<State, Action> extends EventEmitter {
|
|||||||
synced?: KetchupSynced<State, Action>
|
synced?: KetchupSynced<State, Action>
|
||||||
clientId?: number
|
clientId?: number
|
||||||
lastTimestamp?: number
|
lastTimestamp?: number
|
||||||
|
scheduledAck?: NodeJS.Timeout
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
url: string,
|
url: string,
|
||||||
readonly reducer: (state: State, action: Action) => State,
|
readonly reducer: (state: State, action: Action) => State,
|
||||||
private readonly timeSource = Date.now
|
private readonly timeSource = Date.now,
|
||||||
|
private readonly ackDebounce = 5000,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
this.ws = new WebsocketBuilder(url)
|
this.ws = new WebsocketBuilder(url)
|
||||||
@ -19,6 +21,7 @@ export default class KetchupClient<State, Action> extends EventEmitter {
|
|||||||
this.synced = undefined
|
this.synced = undefined
|
||||||
this.clientId = undefined
|
this.clientId = undefined
|
||||||
this.lastTimestamp = undefined
|
this.lastTimestamp = undefined
|
||||||
|
this.cancelAck()
|
||||||
})
|
})
|
||||||
.onOpen(ws => {
|
.onOpen(ws => {
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
@ -41,16 +44,19 @@ export default class KetchupClient<State, Action> extends EventEmitter {
|
|||||||
this.synced = new KetchupSynced(hMsg.confState, hMsg.confBefore, this.reducer, hMsg.unconfActions)
|
this.synced = new KetchupSynced(hMsg.confState, hMsg.confBefore, this.reducer, hMsg.unconfActions)
|
||||||
this.synced.on('projection', proj => this.emit('projection', proj))
|
this.synced.on('projection', proj => this.emit('projection', proj))
|
||||||
this.emit('projection', this.synced.projState)
|
this.emit('projection', this.synced.projState)
|
||||||
|
this.considerAck()
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'action':
|
case 'action':
|
||||||
this.synced!.tryProcess(msg as ActionServerMessage<Action>)
|
this.synced!.tryProcess(msg as ActionServerMessage<Action>)
|
||||||
|
this.considerAck()
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'confirm':
|
case 'confirm':
|
||||||
this.synced!.confirmBefore((msg as ConfirmServerMessage).confBefore)
|
this.synced!.confirmBefore((msg as ConfirmServerMessage).confBefore)
|
||||||
|
this.considerAck()
|
||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown message from server: ${msg}`)
|
throw new Error(`Unknown message from server: ${msg}`)
|
||||||
}
|
}
|
||||||
@ -63,7 +69,8 @@ export default class KetchupClient<State, Action> extends EventEmitter {
|
|||||||
timestamp = this.lastTimestamp + 1
|
timestamp = this.lastTimestamp + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.synced?.tryProcess({ timestamp, action, clientId: this.clientId! })) {
|
if (this.synced!.tryProcess({ timestamp, action, clientId: this.clientId! })) {
|
||||||
|
this.cancelAck()
|
||||||
this.lastTimestamp = timestamp
|
this.lastTimestamp = timestamp
|
||||||
this.ws.send(JSON.stringify({
|
this.ws.send(JSON.stringify({
|
||||||
message: 'action',
|
message: 'action',
|
||||||
@ -72,4 +79,38 @@ export default class KetchupClient<State, Action> extends EventEmitter {
|
|||||||
} as ActionClientMessage<Action>))
|
} as ActionClientMessage<Action>))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
considerAck() {
|
||||||
|
if (this.scheduledAck) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let oldestUnconfTimestamp = this.synced?.oldestUnconfTimestamp
|
||||||
|
if (oldestUnconfTimestamp !== undefined && this.lastTimestamp !== undefined) {
|
||||||
|
let behind = oldestUnconfTimestamp - this.lastTimestamp
|
||||||
|
if (behind > this.ackDebounce) { // Way behind, ACK now
|
||||||
|
this.sendAck()
|
||||||
|
} else if (behind >= 0) { // Somewhat behind, ACK soon if we don't send an action
|
||||||
|
this.scheduledAck = setTimeout(
|
||||||
|
() => { this.sendAck(); this.scheduledAck = undefined },
|
||||||
|
this.ackDebounce - behind,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendAck() {
|
||||||
|
this.lastTimestamp = this.timeSource()
|
||||||
|
this.ws.send(JSON.stringify({
|
||||||
|
message: 'heartbeat',
|
||||||
|
timestamp: this.lastTimestamp,
|
||||||
|
} as HeartbeatClientMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAck() {
|
||||||
|
if (this.scheduledAck) {
|
||||||
|
clearTimeout(this.scheduledAck)
|
||||||
|
this.scheduledAck = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,7 +88,7 @@ export class KetchupSynced<State, Action> extends EventEmitter {
|
|||||||
while (lo < hi) {
|
while (lo < hi) {
|
||||||
const mid = Math.floor((lo + hi) / 2)
|
const mid = Math.floor((lo + hi) / 2)
|
||||||
const comp = compareTimed(x, this.projs[mid])
|
const comp = compareTimed(x, this.projs[mid])
|
||||||
if (comp === 0 ) {
|
if (comp === 0) {
|
||||||
return { idx: mid, exact: true }
|
return { idx: mid, exact: true }
|
||||||
} else if (comp > 0) {
|
} else if (comp > 0) {
|
||||||
lo = mid + 1
|
lo = mid + 1
|
||||||
@ -98,6 +98,10 @@ export class KetchupSynced<State, Action> extends EventEmitter {
|
|||||||
}
|
}
|
||||||
return { idx: lo, exact: false }
|
return { idx: lo, exact: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get oldestUnconfTimestamp() {
|
||||||
|
return this.projs?.[0]?.timestamp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionServerMessage<Action> extends UnconfAction<Action> {
|
export interface ActionServerMessage<Action> extends UnconfAction<Action> {
|
||||||
@ -128,6 +132,11 @@ export interface HandshakeClientMessage {
|
|||||||
message: 'handshake'
|
message: 'handshake'
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
export interface HeartbeatClientMessage {
|
||||||
|
message: 'heartbeat'
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
export type ClientMessage<State, Action> =
|
export type ClientMessage<State, Action> =
|
||||||
ActionClientMessage<Action> |
|
ActionClientMessage<Action> |
|
||||||
HandshakeClientMessage
|
HandshakeClientMessage |
|
||||||
|
HeartbeatClientMessage
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import WebSocket from 'ws'
|
import WebSocket from 'ws'
|
||||||
import min from 'lodash/min'
|
import min from 'lodash/min'
|
||||||
import { ActionClientMessage, KetchupSynced as KetchupSynced, ActionServerMessage, HandshakeServerMessage, ConfirmServerMessage, ClientMessage, HandshakeClientMessage } from 'ketchup-common'
|
import { ActionClientMessage, KetchupSynced as KetchupSynced, ActionServerMessage, HandshakeServerMessage, ConfirmServerMessage, ClientMessage, HandshakeClientMessage, HeartbeatClientMessage } from 'ketchup-common'
|
||||||
|
|
||||||
interface RemoteClient {
|
interface RemoteClient {
|
||||||
syncedAt?: number
|
syncedAt?: number
|
||||||
@ -15,11 +15,11 @@ export default class KetchupServer<State, Action> {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
initState: State,
|
initState: State,
|
||||||
reducer: (state: State, action: Action) => State,
|
reducer: (state: State, action: Action) => State,
|
||||||
) {
|
) {
|
||||||
this.synced = new KetchupSynced<State, Action>(
|
this.synced = new KetchupSynced<State, Action>(
|
||||||
initState,
|
initState,
|
||||||
Date.now() - 1000, // TODO: Handle clients joining with clocks running significantly behind
|
Date.now() - (1000 * 60 * 60 * 24), // TODO: Handle clients joining with clocks running significantly behind
|
||||||
reducer,
|
reducer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -55,6 +55,11 @@ export default class KetchupServer<State, Action> {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'heartbeat':
|
||||||
|
client.syncedAt = (msg as HeartbeatClientMessage).timestamp
|
||||||
|
this.considerConfirm()
|
||||||
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Invalid message from client: ${JSON.stringify(msg)}`)
|
throw new Error(`Invalid message from client: ${JSON.stringify(msg)}`)
|
||||||
}
|
}
|
||||||
@ -93,7 +98,6 @@ export default class KetchupServer<State, Action> {
|
|||||||
private considerConfirm() {
|
private considerConfirm() {
|
||||||
const oldestSync = min([...this.clients.values()].map(c => c.syncedAt))
|
const oldestSync = min([...this.clients.values()].map(c => c.syncedAt))
|
||||||
if (oldestSync !== undefined && this.synced.confirmBefore(oldestSync) > 0) {
|
if (oldestSync !== undefined && this.synced.confirmBefore(oldestSync) > 0) {
|
||||||
console.log('Confirming before', oldestSync)
|
|
||||||
const msg = JSON.stringify({
|
const msg = JSON.stringify({
|
||||||
message: 'confirm',
|
message: 'confirm',
|
||||||
confBefore: this.synced.confBefore,
|
confBefore: this.synced.confBefore,
|
||||||
@ -101,8 +105,6 @@ export default class KetchupServer<State, Action> {
|
|||||||
for (const client of this.clients.values()) {
|
for (const client of this.clients.values()) {
|
||||||
client.ws.send(msg)
|
client.ws.send(msg)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log('Not confirming before', oldestSync)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import useKetchup from 'ketchup-react'
|
import useKetchup from 'ketchup-react'
|
||||||
import { addPlayerAction, reducer, removePlayerAction, resetAction } from 'wonders-common'
|
import { addPlayerAction, reducer, removePlayerAction, resetAction } from 'wonders-common'
|
||||||
import './App.css';
|
import './App.css';
|
||||||
@ -10,9 +10,6 @@ import Row from 'react-bootstrap/Row'
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [state, dispatch] = useKetchup('ws://localhost:4000', reducer)
|
const [state, dispatch] = useKetchup('ws://localhost:4000', reducer)
|
||||||
useEffect(() => {
|
|
||||||
console.debug('State', state)
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
const [user, setUser] = useState<string>()
|
const [user, setUser] = useState<string>()
|
||||||
function selectUser(name?: string) {
|
function selectUser(name?: string) {
|
||||||
@ -29,10 +26,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <Container fluid>
|
return <Container fluid>
|
||||||
<UserSelector user={user} selectUser={selectUser} removeUser={removeUser} users={state?.players.map(p => p.name) ?? []} locked={state !== undefined && state.stage !== 'starting'}/>
|
<UserSelector user={user} selectUser={selectUser} removeUser={removeUser} users={state?.players.map(p => p.name) ?? []} locked={state !== undefined && state.stage !== 'starting'} />
|
||||||
{state
|
{state
|
||||||
? <>
|
? <>
|
||||||
<Game state={state} dispatch={dispatch} playerName={user}/>
|
<Game state={state} dispatch={dispatch} playerName={user} />
|
||||||
<button onClick={() => window.confirm('Reset game?') && dispatch(resetAction())}> Reset</button>
|
<button onClick={() => window.confirm('Reset game?') && dispatch(resetAction())}> Reset</button>
|
||||||
</>
|
</>
|
||||||
: <Row>Loading...</Row>
|
: <Row>Loading...</Row>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user