bemusic/bemuse

View on GitHub
bemuse/src/online/scoreboard-system/createFakeScoreboardClient.ts

Summary

Maintainability
C
1 day
Test Coverage
import {
  ScoreboardClient,
  ScoreboardEntry,
  ScoreboardRow,
} from './ScoreboardClient'

import { MappingMode } from 'bemuse/rules/mapping-mode'
import ObjectID from 'bson-objectid'
import type { ScoreCount } from 'bemuse/rules/accuracy'
import delay from 'delay'

interface Submission {
  md5: string
  playMode: MappingMode
  entry: ScoreboardEntry
}

export function createFakeScoreboardClient(): ScoreboardClient {
  let submissions: Submission[] = []

  let nextId = 1
  const chart = (md5: string, playMode: MappingMode) => {
    const c = {
      addEntry(
        name: string,
        score: ScoreboardEntry['score'],
        count: ScoreboardEntry['count']
      ) {
        submissions.push({
          md5,
          playMode,
          entry: {
            id: 'preexisting' + nextId++,
            score,
            total: count.reduce((a, b) => a + b, 0),
            combo: 1,
            count,
            playNumber: 1,
            playCount: 1,
            recordedAt: '2022-12-31T23:59:59.999Z',
            player: { name },
          },
        })
        return c
      },
    }
    return c
  }
  chart('12345670123456789abcdef89abemuse', 'TS')
    .addEntry('tester', 111111, [0, 0, 0, 1, 0])
    .addEntry('rival', 222222, [0, 0, 0, 1, 0])
    .addEntry('unbeatable', 555554, [1, 0, 0, 0, 0])
  chart('fb3dab834591381a5b8188bc2dc9c4b7', 'KB')
    .addEntry('tester', 543210, [9, 1, 0, 0, 0])
    .addEntry('tester2', 123456, [0, 1, 5, 9, 0])
  const signedUpUsernames = new Set<string>(['taken'])

  const client: ScoreboardClient = {
    signUp: async (options) => {
      await delay(100)
      if (signedUpUsernames.has(options.username)) {
        throw new Error('Username already taken')
      }
      signedUpUsernames.add(options.username)
      return { playerToken: 'FAKE!' + options.username }
    },
    loginByUsernamePassword: async (options) => {
      await delay(100)
      return { playerToken: 'FAKE!' + options.username }
    },
    changePassword: async (options) => {
      return {}
    },
    renewPlayerToken: async (options) => {
      return options.playerToken
    },
    submitScore: async (options) => {
      await delay(100)
      const { username } = decodeFakePlayerToken(options.playerToken)
      const matching = (s: Submission): boolean =>
        s.md5 === options.md5 &&
        s.playMode === options.playMode &&
        s.entry.player.name === username
      const existingSubmission = submissions.find(matching)
      const newScoreboardEntry = updateScoreboardEntry(
        existingSubmission?.entry,
        options.input,
        { name: username }
      )
      submissions = submissions.filter((s) => !matching(s))
      submissions.push({
        md5: options.md5,
        playMode: options.playMode,
        entry: newScoreboardEntry,
      })
      return {
        data: {
          registerScore: {
            resultingRow: getRow(options.md5, options.playMode, username)!,
          },
        },
      }
    },
    retrieveRankingEntries: async (options) => {
      if (!options.md5s.every((x) => typeof x === 'string')) {
        console.error('Invalid md5s...', options.md5s)
        throw new Error('Invalid md5s (this is a programmer error)')
      }
      await delay(100)
      const { username } = decodeFakePlayerToken(options.playerToken)
      const set = new Set<string>(options.md5s)
      return {
        data: {
          me: {
            records: submissions.filter(
              (s) => set.has(s.md5) && s.entry.player.name === username
            ),
          },
        },
      }
    },
    retrieveRecord: async (options) => {
      await delay(100)
      const { username } = decodeFakePlayerToken(options.playerToken)
      return {
        data: {
          chart: {
            level: {
              myRecord: getRow(options.md5, options.playMode, username),
            },
          },
        },
      }
    },
    retrieveScoreboard: async (options) => {
      await delay(100)
      return {
        data: {
          chart: {
            level: {
              leaderboard: getChartSubmissions(
                options.md5,
                options.playMode
              ).map((s, i) => ({ rank: i + 1, entry: s.entry })),
            },
          },
        },
      }
    },
  }

  function getRow(
    md5: string,
    playMode: string,
    username: string
  ): ScoreboardRow | null {
    const chartSubmissions = getChartSubmissions(md5, playMode)
    const mySubmission = chartSubmissions.find(
      (s) => s.entry.player.name === username
    )
    if (!mySubmission) {
      return null
    }
    const myRank =
      chartSubmissions.filter((s) => s.entry.score > mySubmission.entry.score)
        .length + 1
    return {
      rank: myRank,
      entry: mySubmission.entry,
    }
  }

  function getChartSubmissions(md5: string, playMode: string) {
    return submissions
      .filter((s) => s.md5 === md5 && s.playMode === playMode)
      .sort((a, b) => b.entry.score - a.entry.score)
  }

  return client
}

function decodeFakePlayerToken(token: string) {
  if (!token.startsWith('FAKE!')) {
    throw new Error('Invalid player token: ' + token)
  }
  return { username: token.replace(/^FAKE!/, '') }
}

export interface ScoreData {
  score: number
  combo: number
  count: ScoreCount
  total: number
  log: string
}

export function updateScoreboardEntry(
  original: ScoreboardEntry | null | undefined,
  data: ScoreData,
  player: { name: string }
): ScoreboardEntry {
  const nextPlayCount = (original?.playCount || 0) + 1
  const score = +data.score
  if (!original || score > original.score) {
    return Object.assign({}, original || {}, {
      id: original?.id || ObjectID.generate(),
      score: score,
      playCount: nextPlayCount,
      playNumber: nextPlayCount,
      combo: +data.combo || 0,
      count: [
        +data.count[0] || 0,
        +data.count[1] || 0,
        +data.count[2] || 0,
        +data.count[3] || 0,
        +data.count[4] || 0,
      ] as [number, number, number, number, number],
      total: +data.total || 0,
      recordedAt: new Date().toJSON(),
      player: player,
    })
  } else {
    return Object.assign({}, original, {
      playCount: nextPlayCount,
    })
  }
}