Skip to content

Commit

Permalink
feat(multiplayer): Optimize API usage (#494)
Browse files Browse the repository at this point in the history
* feat(multiplayer): don't poll the API
* chore(multiplayer): remove unused room occupancy logic
* chore(multiplayer): remove room occupancy limit
* feat(multiplayer): go offline when server cannot be reached
  • Loading branch information
jeremyckahn authored Apr 30, 2024
1 parent 9cf2a45 commit 27c9692
Show file tree
Hide file tree
Showing 10 changed files with 57 additions and 160 deletions.
2 changes: 0 additions & 2 deletions api-etc/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,3 @@ export const ACCEPTED_ORIGINS = new Set([
'https://www.farmhand.life',
'https://v6p9d9t4.ssl.hwcdn.net', // itch.io's CDN that the game is served from
])

export const MAX_ROOM_SIZE = 25
43 changes: 4 additions & 39 deletions api/get-market-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ const { promisify } = require('util')

require('redis')
require('../src/common/utils')
const { SERVER_ERRORS } = require('../src/common/constants')
const { MAX_ROOM_SIZE } = require('../api-etc/constants')
// End explicit requires for serverless builds

const {
Expand All @@ -15,55 +13,22 @@ const {
getRoomData,
getRoomName,
} = require('../api-etc/utils')
const { HEARTBEAT_INTERVAL_PERIOD } = require('../src/common/constants')

const client = getRedisClient()

const get = promisify(client.get).bind(client)
const set = promisify(client.set).bind(client)

module.exports = allowCors(async (req, res) => {
const { farmId = null } = req.query
const roomKey = getRoomName(req)

const roomData = await getRoomData(roomKey, get, set)
const { activePlayers, valueAdjustments } = roomData
const { valueAdjustments } = roomData

const now = Date.now()

if (farmId) {
activePlayers[farmId] = now
}

let numberOfActivePlayers = 0

const activePlayerIds = Object.keys(activePlayers)

// Multiply HEARTBEAT_INTERVAL_PERIOD by some amount to account for network
// latency and other transient heartbeat delays
const evictionTimeout = HEARTBEAT_INTERVAL_PERIOD * 2.5

// Clean up stale activePlayers data
activePlayerIds.forEach(activePlayerId => {
const timestamp = activePlayers[activePlayerId]
const delta = now - timestamp

if (delta > evictionTimeout) {
delete activePlayers[activePlayerId]
} else {
numberOfActivePlayers++
}
})

// Note: Eviction logic (above) must happen before potentially bailing out
// here to ensure that rooms can drain appropriately.
if (farmId && numberOfActivePlayers > MAX_ROOM_SIZE) {
return res.status(403).json({ errorCode: SERVER_ERRORS.ROOM_FULL })
}

set(roomKey, JSON.stringify({ ...roomData, activePlayers }))
set(roomKey, JSON.stringify(roomData))

res
.status(200)
.json({ activePlayers: numberOfActivePlayers, valueAdjustments })
// TODO: activePlayers: 1 is for legacy backwards compatibility. Remove it after 10/1/2024.
.json({ valueAdjustments, activePlayers: 1 })
})
6 changes: 0 additions & 6 deletions src/common/constants.js
Original file line number Diff line number Diff line change
@@ -1,7 +1 @@
export const MAX_ROOM_NAME_LENGTH = 25

export const HEARTBEAT_INTERVAL_PERIOD = 10 * 1000 // 10 seconds

export const SERVER_ERRORS = {
ROOM_FULL: 'ROOM_FULL',
}
144 changes: 36 additions & 108 deletions src/components/Farmhand/Farmhand.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,23 +90,19 @@ import {
import {
COW_TRADE_TIMEOUT,
DEFAULT_ROOM,
HEARTBEAT_INTERVAL_PERIOD,
INITIAL_STORAGE_LIMIT,
STAGE_TITLE_MAP,
STANDARD_LOAN_AMOUNT,
Z_INDEX,
STANDARD_VIEW_LIST,
} from '../../constants'
import {
HEARTBEAT_INTERVAL_PERIOD,
SERVER_ERRORS,
} from '../../common/constants'
import {
CONNECTED_TO_ROOM,
LOAN_INCREASED,
POSITIONS_POSTED_NOTIFICATION,
RECIPE_LEARNED,
RECIPES_LEARNED,
ROOM_FULL_NOTIFICATION,
} from '../../templates'
import {
CONNECTING_TO_SERVER,
Expand Down Expand Up @@ -638,7 +634,7 @@ export default class Farmhand extends FarmhandReducers {
await this.initializeNewGame()
}

this.syncToRoom().catch(errorCode => this.handleRoomSyncError(errorCode))
this.syncToRoom()

this.setState({ hasBooted: true })
}
Expand Down Expand Up @@ -672,6 +668,7 @@ export default class Farmhand extends FarmhandReducers {

const decodedRoom = decodeURIComponent(newRoom)

// NOTE: This indicates that the client should attempt to connect to the server
const newIsOnline = path.startsWith('/online')

if (newIsOnline !== this.state.isOnline || decodedRoom !== room) {
Expand All @@ -683,7 +680,9 @@ export default class Farmhand extends FarmhandReducers {
}

if (isOnline !== prevState.isOnline || room !== prevState.room) {
this.syncToRoom().catch(errorCode => this.handleRoomSyncError(errorCode))
if (newIsOnline) {
this.syncToRoom()
}

if (!isOnline && typeof heartbeatTimeoutId === 'number') {
clearTimeout(heartbeatTimeoutId)
Expand Down Expand Up @@ -914,25 +913,17 @@ export default class Farmhand extends FarmhandReducers {

this.state.peerRoom?.leave()

const { activePlayers, errorCode, valueAdjustments } = await getData(
endpoints.getMarketData,
{
farmId: this.state.id,
room: room,
}
)

if (errorCode) {
// Bail out and move control to this try's catch
throw new Error(errorCode)
}
const { valueAdjustments } = await getData(endpoints.getMarketData, {
farmId: this.state.id,
room: room,
})

this.scheduleHeartbeat()

const trackerRedundancy = 4

this.setState({
activePlayers,
activePlayers: 1,
peerRoom: joinRoom(
{
appId: process.env.REACT_APP_NAME,
Expand All @@ -951,20 +942,21 @@ export default class Farmhand extends FarmhandReducers {

this.showNotification(CONNECTED_TO_ROOM`${room}`, 'success')
} catch (e) {
const message = e instanceof Error ? e.message : 'Unexpected error'

// TODO: Add some reasonable fallback behavior in case the server request
// fails. Possibility: Regenerate valueAdjustments and notify the user
// they are offline.

if (SERVER_ERRORS[message]) {
// Bubble up the errorCode to be handled by game logic
throw message
}

this.showNotification(SERVER_ERROR, 'error')

console.error(e)

// NOTE: Syncing failed, so take the user offline
this.setState(() => {
return {
redirect: '/',
cowIdOfferedForTrade: '',
}
})
}

this.setState({
Expand All @@ -973,87 +965,16 @@ export default class Farmhand extends FarmhandReducers {
})
}

handleRoomSyncError(errorCode) {
const { room } = this.state

switch (errorCode) {
case SERVER_ERRORS.ROOM_FULL:
const roomNameChunks = room.split('-')
const roomNumber = parseInt(roomNameChunks.slice(-1)[0]) // May be NaN
const nextRoomNumber = isNaN(roomNumber) ? 2 : roomNumber + 1
const roomBaseName = roomNameChunks
.slice(0, isNaN(roomNumber) ? undefined : -1)
.join('-')
const nextRoom = `${roomBaseName}-${nextRoomNumber}`

this.showNotification(
ROOM_FULL_NOTIFICATION`${room}${nextRoom}`,
'warning'
)

this.setState(() => ({
redirect: `/online/${encodeURIComponent(nextRoom)}`,
}))

break

default:
}
}

scheduleHeartbeat() {
const { heartbeatTimeoutId, id, room } = this.state
const { heartbeatTimeoutId } = this.state
clearTimeout(heartbeatTimeoutId ?? -1)

this.setState(() => ({
heartbeatTimeoutId: setTimeout(async () => {
try {
const { activePlayers, errorCode } = await getData(
endpoints.getMarketData,
{
farmId: id,
room,
}
)

if (errorCode) {
// Bail out and move control to this try's catch
throw new Error(errorCode)
}

// If the player has been previously disconnected due to network
// flakiness (see the catch block below), attempt to rejoin the peer
// room.
const peerRoom =
this.state.peerRoom ||
joinRoom(
{
appId: process.env.REACT_APP_NAME,
trackerUrls,
rtcConfig,
},
room
)

this.setState(({ money }) => ({
activePlayers,
money: moneyTotal(money, activePlayers),
peerRoom,
}))
} catch (e) {
const message = e instanceof Error ? e.message : 'Unexpected error'

if (SERVER_ERRORS[message]) {
// Bubble up the errorCode to be handled by game logic
throw message
}

this.showNotification(SERVER_ERROR, 'error')

this.setState({ peerRoom: null })

console.error(e)
}
this.setState(({ money, activePlayers }) => ({
activePlayers,
money: moneyTotal(money, activePlayers),
}))

this.scheduleHeartbeat()
}, HEARTBEAT_INTERVAL_PERIOD),
Expand Down Expand Up @@ -1132,6 +1053,11 @@ export default class Farmhand extends FarmhandReducers {
inventory
)

const { valueAdjustments } = await postData(endpoints.postDayResults, {
positions,
room,
})

if (Object.keys(positions).length) {
serverMessages.push({
message: POSITIONS_POSTED_NOTIFICATION`${'You'}${positions}`,
Expand All @@ -1141,22 +1067,24 @@ export default class Farmhand extends FarmhandReducers {
broadcastedPositionMessage = POSITIONS_POSTED_NOTIFICATION`${''}${positions}`
}

const { valueAdjustments } = await postData(endpoints.postDayResults, {
positions,
room,
})

nextDayState.valueAdjustments = applyPriceEvents(
valueAdjustments,
nextDayState.priceCrashes,
nextDayState.priceSurges
)
} catch (e) {
// NOTE: This will get reached when there's an issue posting data to the server.
serverMessages.push({
message: SERVER_ERROR,
severity: 'error',
})

// NOTE: Takes the user offline
this.setState({
redirect: '/',
cowIdOfferedForTrade: '',
})

console.error(e)
}

Expand Down
2 changes: 1 addition & 1 deletion src/components/Navigation/Navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ const OnlineControls = ({
variant: 'contained',
}}
>
Active players: {integerString(activePlayers)}
Connected players: {integerString(activePlayers)}
</Button>
{isChatAvailable ? (
<Button
Expand Down
4 changes: 3 additions & 1 deletion src/components/OnlinePeersView/OnlinePeersView.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ const OnlinePeersView = ({

return (
<div {...{ className: 'OnlinePeersView' }}>
{activePlayers - 1 > populatedPeers.length && <p>Waiting for peers...</p>}
{activePlayers - 1 > populatedPeers.length && (
<p>Getting player information...</p>
)}
<h3>Your player name</h3>
<Card>
<CardContent>
Expand Down
2 changes: 2 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,5 @@ export const STANDARD_VIEW_LIST = [stageFocusType.SHOP, stageFocusType.FIELD]
export const Z_INDEX = {
END_DAY_BUTTON: 1100,
}

export const HEARTBEAT_INTERVAL_PERIOD = 10 * 1000 // 10 seconds
6 changes: 5 additions & 1 deletion src/game-logic/reducers/addPeer.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/**
* @typedef {import('../../components/Farmhand/Farmhand').farmhand.state} farmhand.state
*/

// TODO: Add tests for this reducer
/**
* @param {farmhand.state} state
Expand All @@ -8,5 +12,5 @@ export const addPeer = (state, peerId) => {
const peers = { ...state.peers }
peers[peerId] = null

return { ...state, peers }
return { ...state, peers, activePlayers: (state.activePlayers ?? 1) + 1 }
}
6 changes: 5 additions & 1 deletion src/game-logic/reducers/removePeer.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/**
* @typedef {import('../../components/Farmhand/Farmhand').farmhand.state} farmhand.state
*/

// TODO: Add tests for this reducer
/**
* @param {farmhand.state} state
Expand All @@ -8,5 +12,5 @@ export const removePeer = (state, peerId) => {
const peers = { ...state.peers }
delete peers[peerId]

return { ...state, peers }
return { ...state, peers, activePlayers: (state.activePlayers ?? 1) - 1 }
}
2 changes: 1 addition & 1 deletion src/strings.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const INVALID_DATA_PROVIDED = 'Invalid Farmhand data provided.'
export const UPDATE_AVAILABLE =
"A game update is available! Click this message to reload and see what's new."
export const SERVER_ERROR =
"There was an issue communicating with the server. You can keep playing offline, and you'll be reconnected as soon as things improve."
'There was an issue connecting to the server. Please try again in a moment.'
export const CONNECTING_TO_SERVER = 'Connecting...'
export const DISCONNECTING_FROM_SERVER = 'Disconnecting...'
export const DISCONNECTED_FROM_SERVER = 'You are now playing offline.'
Expand Down

0 comments on commit 27c9692

Please sign in to comment.