Skip to content

Commit

Permalink
Add an API for submitting game results (does not yet trigger reconcil…
Browse files Browse the repository at this point in the history
…iation).
  • Loading branch information
tec27 committed Oct 18, 2020
1 parent f88202a commit 04429d7
Show file tree
Hide file tree
Showing 3 changed files with 138 additions and 8 deletions.
63 changes: 63 additions & 0 deletions server/lib/api/gameResults.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import httpErrors from 'http-errors'
import { getUserGameRecord, setReportedResults } from '../models/games-users'
import createThrottle from '../throttle/create-throttle'
import throttleMiddleware from '../throttle/middleware'

const throttle = createThrottle('gamesResults', {
rate: 10,
burst: 30,
window: 60000,
})

export default function (router) {
router.post(
'/:gameId',
throttleMiddleware(throttle, ctx => ctx.ip),
submitGameResults,
)
}

// TODO(tec27): clients also need to report the assigned races for each player
async function submitGameResults(ctx, next) {
const { gameId } = ctx.params
const { userId, resultCode, results } = ctx.request.body

if (userId == null) {

This comment has been minimized.

Copy link
@2Pacalypse-

2Pacalypse- Oct 19, 2020

Member

Btw, I read this comment by Dan Abramov about loose checks causing engine deoptimizations while randomly browsing the React repo a while ago: facebook/react#12534 (comment)

Not sure if by "engine" they meant some internal React engine, or the JS engine in general. In either case, I feel like being more specific (or actually less specific since we can just check for a falsy value here?) wouldn't hurt.

This comment has been minimized.

Copy link
@tec27

tec27 Oct 20, 2020

Author Member

Definitely about JS engines I believe. Interesting, perhaps we should flip the ESLint thing we have to require strict checks even in this case.

throw new httpErrors.BadRequest('userId must be specified')
} else if (!resultCode) {
throw new httpErrors.BadRequest('resultCode must be specified')
} else if (!results) {
throw new httpErrors.BadRequest('results must be specified')
} else if (!Array.isArray(results) || !results.length) {
throw new httpErrors.BadRequest('results must be a non-empty array')
}

// TODO(tec27): Check that the results only contain players that are actually in the game
for (const result of results) {
if (
!Array.isArray(result) ||
result.length !== 2 ||
isNaN(result[1]) ||
result[1] < 0 ||
result[1] > 3
) {
throw new httpErrors.BadRequest('results are incorrectly formatted')
}
}

const gameRecord = await getUserGameRecord(userId, gameId)
if (!gameRecord || gameRecord.resultCode !== resultCode) {
// TODO(tec27): Should we be giving this info to clients? Should we be giving *more* info?
throw new httpErrors.NotFound('no matching game found')
}

if (gameRecord.reportedResults) {
throw new httpErrors.Conflict('results already reported')
}

await setReportedResults({ userId, gameId, reportedResults: results, reportedAt: new Date() })

ctx.status = 204

// TODO(tec27): check if this game now has complete results
}
64 changes: 64 additions & 0 deletions server/lib/models/games-users.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,67 @@ export async function deleteUserRecordsForGame(gameId) {
done()
}
}

/**
* Retrieves a particular user-specific game record.
*
* @param userId
* @param gameId
*
* @returns an object containing the information about the game, or null if there is no such game
*/
export async function getUserGameRecord(userId, gameId) {
const { client, done } = await db()

try {
const result = await client.query(
sql`SELECT * FROM games_users WHERE user_id = ${userId} AND game_id = ${gameId}`,
)
if (!result.rows.length) {
return null
}

const row = result.rows[0]

return {
userId: row.user_id,
gameId: row.game_id,
startTime: row.start_time,
selectedRace: row.selected_race,
resultCode: row.result_code,
reportedResults: row.reported_results,
reportedAt: row.reported_at,
assignedRace: row.assignedRace,
result: row.result,
}
} finally {
done()
}
}

/**
* Updates a particular user's results for a game.
*
* Results should be an array of entries containing [playerName, resultCode], where resultCode can
* be:
*
* - 0: playing
* - 1: disconnected
* - 2: victory
* - 3: defeat
*/
export async function setReportedResults({ userId, gameId, reportedResults, reportedAt }) {
const { client, done } = await db()

try {
await client.query(sql`
UPDATE games_users
SET
reported_results = ${JSON.stringify(reportedResults)},
reported_at = ${reportedAt}
WHERE user_id = ${userId} AND game_id = ${gameId}
`)
} finally {
done()
}
}
19 changes: 11 additions & 8 deletions server/lib/throttle/create-throttle.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,17 @@ class IoredisTable extends RedisTable {
}
}

// Creates a new throttle object using the specified options and our usual redis client.
// The `name` is used in the redis key.
// Options are:
// - window: the number of milliseconds in which `rate` and `burst` act
// - rate: how many tokens are refreshed every `window` amount of time
// - burst: maximum number of requests allowed in a `window` amount of time
// - expiry: how long the token bucket keys in redis should be set to expire for, in seconds
// (default: 10 * (`burst` / `rate`) `window`s)
/**
* Creates a new throttle object using the specified options and our usual redis client. The `name`
* is used in the redis key.
*
* Options are:
* - window: the number of milliseconds in which `rate` and `burst` act
* - rate: how many tokens are refreshed every `window` amount of time
* - burst: maximum number of requests allowed in a `window` amount of time
* - expiry: how long the token bucket keys in redis should be set to expire for, in seconds
* (default: 10 * (`burst` / `rate`) `window`s)
*/
export default function createThrottle(name, opts) {
const table = new IoredisTable(redisClient, {
prefix: 'sbthrottle:' + name,
Expand Down

0 comments on commit 04429d7

Please sign in to comment.