diff --git a/db/migrations/20160804122354-player-stats.js b/db/migrations/20160804122354-player-stats.js new file mode 100644 index 00000000..44de34a2 --- /dev/null +++ b/db/migrations/20160804122354-player-stats.js @@ -0,0 +1,84 @@ +const config = require('../config') + +config() + +exports.up = async (r, conn) => { + const players = await r.table('players').run(conn) + + const updates = players.map(player => { + const ecc = player.ecc + const projects = {} + + const cycles = player.cycleProjectECC || {} + Object.keys(cycles).forEach(cycleId => { + const cycleProjects = cycles[cycleId] || {} + + Object.keys(cycleProjects).forEach(projectId => { + const cycleProjectStats = cycleProjects[projectId] || {} + + if (!projects[projectId]) { + projects[projectId] = {cycles: {}} + } + if (!projects[projectId].cycles[cycleId]) { + projects[projectId].cycles[cycleId] = {} + } + + projects[projectId].cycles[cycleId].abc = cycleProjectStats.abc || 0 + projects[projectId].cycles[cycleId].rc = cycleProjectStats.rc || 0 + projects[projectId].cycles[cycleId].ecc = cycleProjectStats.ecc || 0 + }) + }) + + return r.table('players') + .get(player.id) + .replace( + r.row + .merge({stats: {ecc, projects}}) + .without('ecc', 'cycleProjectECC') + ) + .run(conn) + }) + + return Promise.all(updates) +} + +exports.down = async (r, conn) => { + const players = await r.table('players').run(conn) + + const updates = players.map(player => { + const stats = player.stats || {} + const ecc = stats.ecc || 0 + const cycleProjectECC = {} + + const projects = stats.projects || {} + Object.keys(projects).forEach(projectId => { + const projectCycles = (projects[projectId] || {}).cycles || {} + + Object.keys(projectCycles).forEach(cycleId => { + const projectCycleStats = projectCycles[cycleId] || {} + + if (!cycleProjectECC[cycleId]) { + cycleProjectECC[cycleId] = {} + } + if (!cycleProjectECC[cycleId][projectId]) { + cycleProjectECC[cycleId][projectId] = {} + } + + cycleProjectECC[cycleId][projectId].abc = projectCycleStats.abc + cycleProjectECC[cycleId][projectId].rc = projectCycleStats.rc + cycleProjectECC[cycleId][projectId].ecc = projectCycleStats.ecc + }) + }) + + return r.table('players') + .get(player.id) + .replace( + r.row + .merge({ecc, cycleProjectECC}) + .without('stats') + ) + .run(conn) + }) + + return Promise.all(updates) +} diff --git a/package.json b/package.json index 167e0148..57f278fa 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,10 @@ "data:github-goals": "./node_modules/.bin/babel-node test/generateTestGoals", "db:create": "./node_modules/.bin/babel-node ./db/create.js", "db:drop": "./node_modules/.bin/babel-node ./db/drop.js", - "db:migrate:configure": "node ./db/config.js > ./db/database.json", - "db:migrate": "npm run db:migrate:configure && node ./node_modules/.bin/rethink-migrate -r db", - "db:migrate:up": "npm run db:migrate:configure && node ./node_modules/.bin/rethink-migrate -r db up", - "db:migrate:down": "npm run db:migrate:configure && node ./node_modules/.bin/rethink-migrate -r db down", + "db:migrate:configure": "./node_modules/.bin/babel-node ./db/config.js > ./db/database.json", + "db:migrate": "npm run db:migrate:configure && ./node_modules/.bin/babel-node ./node_modules/.bin/rethink-migrate -r db", + "db:migrate:up": "npm run db:migrate:configure && ./node_modules/.bin/babel-node ./node_modules/.bin/rethink-migrate -r db up", + "db:migrate:down": "npm run db:migrate:configure && ./node_modules/.bin/babel-node ./node_modules/.bin/rethink-migrate -r db down", "start": "npm run icons:fetch && node server", "workers": "./node_modules/.bin/babel-node ./server/workers", "postinstall": "npm run build", diff --git a/server/actions/__tests__/formProjects.test.js b/server/actions/__tests__/formProjects.test.js index b806bb29..07c79e84 100644 --- a/server/actions/__tests__/formProjects.test.js +++ b/server/actions/__tests__/formProjects.test.js @@ -162,7 +162,7 @@ async function _generatePlayers(chapterId, options = {}) { const numAdvanced = options.advanced || 0 return { regular: await factory.createMany('player', {chapterId}, numTotal - numAdvanced), - advanced: await factory.createMany('player', {chapterId, ecc: TEST_ADVANCED_PLAYER_ECC}, numAdvanced) + advanced: await factory.createMany('player', {chapterId, stats: {ecc: TEST_ADVANCED_PLAYER_ECC}}, numAdvanced) } } diff --git a/server/actions/__tests__/updateTeamECCStats.test.js b/server/actions/__tests__/updateProjectStats.test.js similarity index 69% rename from server/actions/__tests__/updateTeamECCStats.test.js rename to server/actions/__tests__/updateProjectStats.test.js index abe739e1..4820ca2c 100644 --- a/server/actions/__tests__/updateTeamECCStats.test.js +++ b/server/actions/__tests__/updateProjectStats.test.js @@ -7,35 +7,35 @@ import {withDBCleanup, useFixture} from '../../../test/helpers' import {getPlayerById} from '../../../server/db/player' import { - calculateProjectECCStatsForPlayer, - updateTeamECCStats, -} from '../updateTeamECCStats' + calculatePlayerProjectStats, + updateProjectStats, +} from '../updateProjectStats' describe(testContext(__filename), function () { - describe('calculateProjectECCStatsForPlayer()', function () { + describe('calculatePlayerProjectStats()', function () { specify('when there are scores from all team members', function () { - expect(calculateProjectECCStatsForPlayer({teamSize: 4, relativeContributionScores: [10, 20, 20, 30]})) + expect(calculatePlayerProjectStats({teamSize: 4, relativeContributionScores: [10, 20, 20, 30]})) .to.deep.eq({ecc: 80, abc: 4, rc: 20}) }) specify('when there are not scores from all team members', function () { - expect(calculateProjectECCStatsForPlayer({teamSize: 4, relativeContributionScores: [20, 25, 30]})) + expect(calculatePlayerProjectStats({teamSize: 4, relativeContributionScores: [20, 25, 30]})) .to.deep.eq({ecc: 100, abc: 4, rc: 25}) }) specify('when the result is over 100', function () { - expect(calculateProjectECCStatsForPlayer({teamSize: 4, relativeContributionScores: [50, 50, 50, 50]})) + expect(calculatePlayerProjectStats({teamSize: 4, relativeContributionScores: [50, 50, 50, 50]})) .to.deep.eq({ecc: 200, abc: 4, rc: 50}) }) specify('when project length is > 1', function () { - expect(calculateProjectECCStatsForPlayer({teamSize: 4, relativeContributionScores: [50, 50, 50, 50], projectLength: 3})) + expect(calculatePlayerProjectStats({teamSize: 4, relativeContributionScores: [50, 50, 50, 50], buildCycles: 3})) .to.deep.eq({ecc: 600, abc: 12, rc: 50}) }) specify('when RC is a decimal, round', function () { - expect(calculateProjectECCStatsForPlayer({teamSize: 5, relativeContributionScores: [10, 10, 21, 21]})) + expect(calculatePlayerProjectStats({teamSize: 5, relativeContributionScores: [10, 10, 21, 21]})) .to.deep.eq({ecc: 80, abc: 5, rc: 16}) }) }) - describe('updateTeamECCStats', function () { + describe('updateProjectStats', function () { withDBCleanup() useFixture.buildSurvey() @@ -64,10 +64,10 @@ describe(testContext(__filename), function () { it('updates the players ECC based on the survey responses', async function() { const eccChange = 20 * this.teamPlayerIds.length - await updateTeamECCStats(this.project, this.cycleId) + await updateProjectStats(this.project, this.cycleId) const updatedPlayer = await getPlayerById(this.teamPlayerIds[0]) - expect(updatedPlayer.ecc).to.eq(eccChange) + expect(updatedPlayer.stats.ecc).to.eq(eccChange) }) }) }) diff --git a/server/actions/formProjects.js b/server/actions/formProjects.js index db0fcbbd..d2c3930c 100644 --- a/server/actions/formProjects.js +++ b/server/actions/formProjects.js @@ -51,7 +51,9 @@ function _formGoalGroups(players, playerVotes) { const regularPlayers = new Map() players.forEach(player => { - if (parseInt(player.ecc, 10) >= MIN_ADVANCED_PLAYER_ECC) { + const playerECC = parseInt((player.stats || {}).ecc, 10) || 0 + + if (playerECC >= MIN_ADVANCED_PLAYER_ECC) { advancedPlayers.set(player.id, player) } else { regularPlayers.set(player.id, player) diff --git a/server/actions/updateTeamECCStats.js b/server/actions/updateProjectStats.js similarity index 77% rename from server/actions/updateTeamECCStats.js rename to server/actions/updateProjectStats.js index 1111102a..ab2ad3c9 100644 --- a/server/actions/updateTeamECCStats.js +++ b/server/actions/updateProjectStats.js @@ -1,12 +1,12 @@ import {getSurveyById} from '../../server/db/survey' import {getRelativeContributionQuestionForSurvey} from '../../server/db/question' import {getSurveyResponses} from '../../server/db/response' -import {updatePlayerECCStats} from '../../server/db/player' +import {savePlayerProjectStats} from '../../server/db/player' import { getProjectHistoryForCycle, } from '../../server/db/project' -export async function updateTeamECCStats(project, cycleId) { +export async function updateProjectStats(project, cycleId) { const projectCycle = getProjectHistoryForCycle(project, cycleId) const teamSize = projectCycle.playerIds.length const surveyId = projectCycle.retrospectiveSurveyId @@ -17,16 +17,16 @@ export async function updateTeamECCStats(project, cycleId) { const promises = [] responsesBySubjectId.forEach((responses, subjectPlayerId) => { const relativeContributionScores = responses.map(({value}) => value) - const projectECC = calculateProjectECCStatsForPlayer({teamSize, relativeContributionScores}) - promises.push(updatePlayerECCStats(subjectPlayerId, projectECC, cycleId, project.id)) + const subjectPlayerStats = calculatePlayerProjectStats({teamSize, relativeContributionScores}) + promises.push(savePlayerProjectStats(subjectPlayerId, project.id, cycleId, subjectPlayerStats)) }) await Promise.all(promises) } -export function calculateProjectECCStatsForPlayer({teamSize, relativeContributionScores, projectLength}) { +export function calculatePlayerProjectStats({buildCycles, teamSize, relativeContributionScores}) { // Calculate ABC - const aggregateBuildCycles = (projectLength || 1) * teamSize + const aggregateBuildCycles = (buildCycles || 1) * teamSize // Calculate RC const sum = relativeContributionScores.reduce((sum, next) => sum + next, 0) diff --git a/server/db/__tests__/player.test.js b/server/db/__tests__/player.test.js index 0ccdb194..bdc0bf12 100644 --- a/server/db/__tests__/player.test.js +++ b/server/db/__tests__/player.test.js @@ -7,9 +7,9 @@ import factory from '../../../test/factories' import {withDBCleanup} from '../../../test/helpers' import { - reassignPlayersToChapter, getPlayerById, - updatePlayerECCStats, + reassignPlayersToChapter, + savePlayerProjectStats, } from '../player' describe(testContext(__filename), function () { @@ -91,92 +91,112 @@ describe(testContext(__filename), function () { }) }) - describe('updatePlayerECCStats', function () { + describe('savePlayerProjectStats', function () { beforeEach(async function () { this.projectIds = [await r.uuid(), await r.uuid()] this.cycleIds = [await r.uuid(), await r.uuid()] - this.player = await factory.create('player', {ecc: 0}) + this.player = await factory.create('player', {stats: {ecc: 0}}) this.fetchPlayer = () => getPlayerById(this.player.id) }) it('creates the ecc attribute if missing', async function() { - await getPlayerById(this.player.id).replace(p => p.without('ecc')) + await getPlayerById(this.player.id).replace(p => p.without('stats')) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], {ecc: 40, abc: 4, rc: 10}) - await updatePlayerECCStats(this.player.id, {ecc: 40, abc: 4, rc: 10}, this.cycleIds[0], this.projectIds[0]) + const player = await this.fetchPlayer() - expect(await this.fetchPlayer()).to.have.property('ecc', 40) + expect(player.stats.ecc).to.eq(40) }) - it('adds to the existing ECC', async function() { - expect(this.player).to.have.property('ecc') - await getPlayerById(this.player.id).update({ecc: 10}) + it('adds to the existing cumulative ECC', async function() { + expect(this.player).to.have.deep.property('stats.ecc') + + await getPlayerById(this.player.id).update({stats: {ecc: 10}}) + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], {ecc: 20, abc: 4, rc: 5}) - await updatePlayerECCStats(this.player.id, {ecc: 20, abc: 4, rc: 5}, this.cycleIds[1], this.projectIds[1]) + const player = await this.fetchPlayer() - expect(await this.fetchPlayer()).to.have.property('ecc', 30) + expect(player.stats.ecc).to.eq(30) }) - it('creates the cycleProjectECC attr if neccessary', async function () { - expect(this.player).to.not.have.property('cycleProjectECC') + it('creates the stats.projects attribute if neccessary', async function () { + expect(this.player).to.not.have.deep.property('stats.projects') const stats = {ecc: 20, abc: 4, rc: 5} - await updatePlayerECCStats(this.player.id, stats, this.cycleIds[0], this.projectIds[0]) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], stats) - expect(await this.fetchPlayer()).to.have.property('cycleProjectECC').and.deep.eq({ - [this.cycleIds[0]]: {[this.projectIds[0]]: stats} + const player = await this.fetchPlayer() + + expect(player.stats.ecc).to.eq(20) + expect(player.stats.projects).to.deep.eq({ + [this.projectIds[0]]: { + cycles: {[this.cycleIds[0]]: stats} + }, }) }) - it('adds an item to the existing cycleProjectECC if needed', async function () { - expect(this.player).to.not.have.property('cycleProjectECC') + it('adds a project entry to the stats if neccessary', async function () { + expect(this.player).to.not.have.deep.property('stats.projects') const stats = [ {ecc: 20, abc: 4, rc: 5}, {ecc: 18, abc: 3, rc: 6}, ] - await updatePlayerECCStats(this.player.id, stats[0], this.cycleIds[0], this.projectIds[0]) - await updatePlayerECCStats(this.player.id, stats[1], this.cycleIds[1], this.projectIds[1]) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], stats[0]) + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], stats[1]) - expect(await this.fetchPlayer()).to.have.property('cycleProjectECC').and.deep.eq({ - [this.cycleIds[0]]: {[this.projectIds[0]]: stats[0]}, - [this.cycleIds[1]]: {[this.projectIds[1]]: stats[1]}, + const player = await this.fetchPlayer() + + expect(player.stats.ecc).to.eq(38) + expect(player.stats.projects).to.deep.eq({ + [this.projectIds[0]]: { + cycles: {[this.cycleIds[0]]: stats[0]} + }, + [this.projectIds[1]]: { + cycles: {[this.cycleIds[1]]: stats[1]} + }, }) }) - it('adds project ecc to the existing cycleProjectECC item if present', async function () { - expect(this.player).to.not.have.property('cycleProjectECC') + it('adds a cycle entry to the project stats if needed', async function () { + expect(this.player).to.not.have.deep.property('stats.projects') const stats = [ {ecc: 20, abc: 4, rc: 5}, {ecc: 18, abc: 3, rc: 6}, ] - await updatePlayerECCStats(this.player.id, stats[0], this.cycleIds[0], this.projectIds[0]) - await updatePlayerECCStats(this.player.id, stats[1], this.cycleIds[0], this.projectIds[1]) - - expect(await this.fetchPlayer()).to.have.property('cycleProjectECC').and.deep.eq({ - [this.cycleIds[0]]: { - [this.projectIds[0]]: stats[0], - [this.projectIds[1]]: stats[1], + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], stats[0]) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[1], stats[1]) + + const player = await this.fetchPlayer() + + expect(player.stats.ecc).to.eq(38) + expect(player.stats.projects).to.deep.eq({ + [this.projectIds[0]]: { + cycles: { + [this.cycleIds[0]]: stats[0], + [this.cycleIds[1]]: stats[1], + }, }, }) }) it('when called for the same project/cycle more than once, the result is the same as if only the last call were made', async function () { // Initialize the player with an ECC of 10 - await updatePlayerECCStats(this.player.id, {ecc: 10, abc: 2, rc: 5}, this.cycleIds[0], this.projectIds[0]) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], {ecc: 10, abc: 2, rc: 5}) // Add 20 for a project - await updatePlayerECCStats(this.player.id, {ecc: 20, abc: 4, rc: 5}, this.cycleIds[1], this.projectIds[1]) - expect(await this.fetchPlayer()).to.have.property('ecc', 30) + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], {ecc: 20, abc: 4, rc: 5}) + expect(await this.fetchPlayer()).to.have.deep.property('stats.ecc', 30) expect(await this.fetchPlayer()).to.have.deep - .property(`cycleProjectECC.${this.cycleIds[1]}.${this.projectIds[1]}.ecc`, 20) + .property(`stats.projects.${this.projectIds[1]}.cycles.${this.cycleIds[1]}.ecc`, 20) // Change the ECC for that project to 10 const stats = {ecc: 10, abc: 2, rc: 5} - await updatePlayerECCStats(this.player.id, stats, this.cycleIds[1], this.projectIds[1]) - expect(await this.fetchPlayer()).to.have.property('ecc', 20) + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], stats) + expect(await this.fetchPlayer()).to.have.deep.property('stats.ecc', 20) expect(await this.fetchPlayer()).to.have.deep - .property(`cycleProjectECC.${this.cycleIds[1]}.${this.projectIds[1]}`).deep.eq(stats) + .property(`stats.projects.${this.projectIds[1]}.cycles.${this.cycleIds[1]}`).deep.eq(stats) }) }) }) diff --git a/server/db/player.js b/server/db/player.js index afc9c653..7036961c 100644 --- a/server/db/player.js +++ b/server/db/player.js @@ -1,8 +1,6 @@ import r from '../../db/connect' import {updateInTable} from '../../server/db/util' -export const playersTable = r.table('players') - export function getPlayerById(id, passedOptions = {}) { const options = Object.assign({ mergeChapter: false, @@ -23,25 +21,29 @@ export function findPlayersByIds(playerIds) { return r.table('players').getAll(...playerIds) } -export function updatePlayerECCStats(playerId, stats, cycleId, projectId) { - const deltaECC = stats.ecc - const cycleProjectECC = r.row('cycleProjectECC').default({}) - const eccAlreadyRecordedForProject = cycleProjectECC(cycleId).default({}).hasFields(projectId) - const previousECCForProject = cycleProjectECC(cycleId)(projectId)('ecc') - const newECC = r.branch( - eccAlreadyRecordedForProject, - r.row('ecc').sub(previousECCForProject).add(deltaECC), - r.row('ecc').add(deltaECC).default(deltaECC), - ) +export function savePlayerProjectStats(playerId, projectId, cycleId, newStats = {}) { + const playerStats = r.row('stats').default({}) + const playerProjectStats = playerStats('projects').default({}) + const playerProjectCycleStats = playerProjectStats(projectId).default({})('cycles').default({}) + const playerProjectCycleECC = playerProjectCycleStats(cycleId).default({})('ecc').default(0) - const newCycleProjectECC = cycleProjectECC.merge(row => ({ - [cycleId]: row(cycleId).default({}).merge({[projectId]: stats}) + const mergedProjectStats = playerProjectStats.merge(projects => ({ + [projectId]: projects(projectId).default({}).merge(project => ({ + cycles: project('cycles').default({}).merge(cycles => ({ + [cycleId]: cycles(cycleId).default({}).merge(newStats) + })) + })) })) + const currentECC = playerStats('ecc').default(0) + const updatedECC = currentECC.sub(playerProjectCycleECC).add(newStats.ecc || 0) + return update({ id: playerId, - ecc: newECC, - cycleProjectECC: newCycleProjectECC, + stats: { + ecc: updatedECC, + projects: mergedProjectStats, + } }) } @@ -82,5 +84,5 @@ export function findPlayersForChapter(chapterId, filters) { } export function update(record, options) { - return updateInTable(record, playersTable, options) + return updateInTable(record, r.table('players'), options) } diff --git a/server/workers/cycleCompleted.js b/server/workers/cycleCompleted.js index be3e6f79..cf8e4992 100644 --- a/server/workers/cycleCompleted.js +++ b/server/workers/cycleCompleted.js @@ -1,7 +1,7 @@ import {getQueue} from '../util' import ChatClient from '../../server/clients/ChatClient' import r from '../../db/connect' -import {updateTeamECCStats} from '../../server/actions/updateTeamECCStats' +import {updateProjectStats} from '../../server/actions/updateProjectStats' import {getProjectsForChapterInCycle} from '../../server/db/project' export function start() { @@ -14,15 +14,15 @@ export function start() { export async function processCompletedCycle(cycle, chatClient = new ChatClient()) { console.log(`Completing cycle ${cycle.cycleNumber} of chapter ${cycle.chapterId}`) - await updateECC(cycle) + await updateStats(cycle) await sendCompletionAnnouncement(cycle, chatClient) } -function updateECC(cycle) { +function updateStats(cycle) { return getProjectsForChapterInCycle(cycle.chapterId, cycle.id) .then(projects => Promise.all( - projects.map(project => updateTeamECCStats(project, cycle.id)) + projects.map(project => updateProjectStats(project, cycle.id)) ) ) } diff --git a/test/factories/player.js b/test/factories/player.js index 5868b8c8..c756362c 100644 --- a/test/factories/player.js +++ b/test/factories/player.js @@ -8,7 +8,7 @@ export default function define(factory) { id: cb => cb(null, faker.random.uuid()), chapterId: factory.assoc('chapter', 'id'), chapterHistory: [], - ecc: 0, + stats: {ecc: 0}, active: true, createdAt: cb => cb(null, now), updatedAt: cb => cb(null, now), diff --git a/test/generatePlaytestData.js b/test/generatePlaytestData.js index 01dfdc1f..3da64972 100644 --- a/test/generatePlaytestData.js +++ b/test/generatePlaytestData.js @@ -116,7 +116,9 @@ function createPlayersOrModerators(table, users, chapter) { if (table === 'players') { data.active = true - data.ecc = i % 5 === 0 ? 50000 : 0 // every 5th player is a "super advanced player" + data.stats = { + ecc: i % 5 === 0 ? 50000 : 0 // every 5th player is a "super advanced player" + } } return data diff --git a/test/generateTestData.js b/test/generateTestData.js index f1a5f2ba..8ad8359e 100644 --- a/test/generateTestData.js +++ b/test/generateTestData.js @@ -24,7 +24,7 @@ function createPlayers(users, chapters) { id: user.id, chapterId: chapterMap[user.inviteCode].id, active: true, - ecc: 0, + stats: {ecc: 0}, } })