Skip to content

Commit

Permalink
feat: #20 use time to answer as tiebreaker in scoring
Browse files Browse the repository at this point in the history
  • Loading branch information
stoerti committed Sep 23, 2024
1 parent a3f0bb8 commit efb7654
Show file tree
Hide file tree
Showing 14 changed files with 69 additions and 19 deletions.
3 changes: 2 additions & 1 deletion backend/src/main/kotlin/org/quizmania/game/api/commands.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ data class AnswerQuestionCommand(
override val gameId: UUID,
val gameQuestionId: UUID,
val username: String,
val answer: String
val answer: String,
val answerTimestamp: Instant,
): GameCommand

data class OverrideAnswerCommand(
Expand Down
3 changes: 2 additions & 1 deletion backend/src/main/kotlin/org/quizmania/game/api/events.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ data class QuestionAnsweredEvent(
override val gameQuestionId: GameQuestionId,
val gamePlayerId: GamePlayerId,
val playerAnswerId: UUID,
val answer: String
val answer: String,
val timeToAnswer: Long,
) : GameQuestionEvent

data class QuestionAnswerOverriddenEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ internal class GameAggregate() {

val player = players.getByUsername(command.username)
val gameQuestion = askedQuestions.getById(command.gameQuestionId)
gameQuestion.answer(player.gamePlayerId, command.answer)
gameQuestion.answer(player.gamePlayerId, command.answer, command.answerTimestamp)

// after QuestionAnsweredEvent is applied, the player-answer is actually in the list
if (players.size == gameQuestion.numAnswers()) {
Expand Down Expand Up @@ -278,6 +278,7 @@ internal class GameAggregate() {
playerAnswers = mutableListOf(),
playerBuzzes = mutableListOf(),
isModerated = moderatorUsername != null,
questionAskedTimestamp = event.questionTimestamp
)
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.quizmania.game.command.application.domain

import org.axonframework.eventhandling.Timestamp
import org.axonframework.eventsourcing.EventSourcingHandler
import org.axonframework.modelling.command.AggregateLifecycle
import org.axonframework.modelling.command.EntityId
Expand All @@ -19,6 +20,7 @@ data class GameQuestion(
val number: GameQuestionNumber,
val question: Question,
val questionMode: GameQuestionMode,
val questionAskedTimestamp: Instant,
private var playerAnswers: MutableList<PlayerAnswer> = mutableListOf(),
private var playerBuzzes: MutableList<PlayerBuzz> = mutableListOf(),
private var currentBuzzWinner: GamePlayerId? = null,
Expand Down Expand Up @@ -72,7 +74,7 @@ data class GameQuestion(
}
}

fun answer(gamePlayerId: GamePlayerId, answer: String) {
fun answer(gamePlayerId: GamePlayerId, answer: String, answerTimestamp: Instant) {
if (this.questionMode == GameQuestionMode.BUZZER) {
throw QuestionInBuzzerModeProblem(this.gameId, this.id)
}
Expand All @@ -86,7 +88,8 @@ data class GameQuestion(
gameQuestionId = id,
gamePlayerId = gamePlayerId,
playerAnswerId = UUID.randomUUID(),
answer = answer
answer = answer,
answerTimestamp.toEpochMilli() - this.questionAskedTimestamp.toEpochMilli()
)
)
}
Expand Down Expand Up @@ -178,7 +181,8 @@ data class GameQuestion(
gameQuestionId = id,
gamePlayerId = this.currentBuzzWinner!!,
playerAnswerId = UUID.randomUUID(),
answer = question.correctAnswer
answer = question.correctAnswer,
0
)
)
AggregateLifecycle.apply(
Expand All @@ -196,7 +200,8 @@ data class GameQuestion(
gameQuestionId = id,
gamePlayerId = this.currentBuzzWinner!!,
playerAnswerId = UUID.randomUUID(),
answer = "" // some empty wrong answer - TODO better concept?
answer = "", // some empty wrong answer - TODO better concept?
0L
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ class GameCommandController(
gameId = gameId,
gameQuestionId = answer.gameQuestionId,
username = username,
answer = answer.answer
answer = answer.answer,
answer.answerTimestamp
)
)
return ResponseEntity.ok().build()
Expand Down Expand Up @@ -196,7 +197,8 @@ data class NewGameDto(

data class AnswerDto(
val gameQuestionId: UUID,
val answer: String
val answer: String,
val answerTimestamp: Instant
)

data class BuzzDto(
Expand Down
2 changes: 1 addition & 1 deletion e2e/codecept.conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const config: CodeceptJS.MainConfig = {
helpers: {
Playwright: {
browser: 'chromium',
url: 'http://localhost:8080',
url: 'http://localhost:5173',
show: true
}
},
Expand Down
2 changes: 1 addition & 1 deletion e2e/test/multiplayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Scenario('multiplayer_moderated', ({I, loginPage, lobbyPage, gameRoomPage}) => {
I.waitForText(username2)
I.waitForText(username3)

I.wait(5)
I.wait(5000)

gameRoomPage.startGame()

Expand Down
27 changes: 25 additions & 2 deletions frontend/src/domain/GameModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class Game {
readonly moderator: string | undefined;
readonly status: GameStatus;
readonly players: Player[];
readonly numQuestions: number = 0;
readonly currentQuestion: GameQuestion | undefined;

constructor(event: GameCreatedEvent) {
Expand Down Expand Up @@ -120,6 +121,7 @@ export class Game {

public onQuestionAsked(event: QuestionAskedEvent): Game {
return this.copyWith({
numQuestions: this.numQuestions + 1,
currentQuestion:
new GameQuestion(event)
})
Expand All @@ -136,7 +138,22 @@ export class Game {
}

public onQuestionAnswered(event: QuestionAnsweredEvent): Game {
return this.updateQuestion(event.gameQuestionId, question => question.onQuestionAnswered(event))
const game = this.updateQuestion(event.gameQuestionId, question => question.onQuestionAnswered(event))

const newPlayers = game.players.map(player => {
if (event.gamePlayerId === player.id) {
return {
...player,
totalAnswerTime: player.totalAnswerTime + event.timeToAnswer
}
} else {
return player
}
})

return game.copyWith({
players: newPlayers
})
}

public onQuestionAnswerOverridden(event: QuestionAnswerOverriddenEvent): Game {
Expand Down Expand Up @@ -182,6 +199,11 @@ export class Game {
public findPlayerPoints(gamePlayerId: string): number {
return this.players.find(player => player.id === gamePlayerId)?.points ?? 0
}

public findAverageAnswerTime(gamePlayerId: string): number {
const totalAnswerTime = this.players.find(player => player.id === gamePlayerId)?.totalAnswerTime ?? 0
return totalAnswerTime / this.numQuestions
}
}

export class GameQuestion {
Expand Down Expand Up @@ -227,7 +249,7 @@ export class GameQuestion {
return this.copyWith({
answers: [
...this.answers.filter(answer => answer.gamePlayerId != event.gamePlayerId),
new Answer(event)
new Answer({...event, timeToAnswer: 0})
]
})
}
Expand Down Expand Up @@ -287,6 +309,7 @@ export class Player {
readonly id: string;
readonly name: string;
readonly points: number = 0;
readonly totalAnswerTime: number = 0;

constructor(id: string, name: string) {
this.id = id
Expand Down
1 change: 1 addition & 0 deletions frontend/src/domain/__tests__/GameModel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ function questionAnswered(id: string, playerId: string, answerId: string, answer
gamePlayerId: playerId,
playerAnswerId: answerId,
answer: answer,
timeToAnswer: 0,
}
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/GameSelectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const GameSelectionPage = () => {
console.log('no username set, redirect to login');
navigate('/login');
}
}, [username]);
}, [username, navigate]);

useEffect(() => {
gameOverviewService.searchOpenGames(setGames)
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/pages/game/gameroom/Scoreboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import useWindowDimensions from "../../../hooks/useWindowDimensions.tsx";
import {useUsername} from "../../../hooks/useUsername.ts";


const comparePlayersByPointsAndName = function (p1: Player, p2: Player): number {
const comparePlayersByPointsAndAnswerTime = function (p1: Player, p2: Player): number {
if (p1.points < p2.points)
return 1
if (p1.points > p2.points)
return -1

return p1.name.localeCompare(p2.name)
return p1.totalAnswerTime - p2.totalAnswerTime
}

export type ScoreboardProps = {
Expand Down Expand Up @@ -45,7 +45,7 @@ const ScoreboardPage = ({game, page, pageSize}: ScoreboardPageProps) => {
</TableRow>
</TableHead>
<TableBody>
{[...game.players].sort(comparePlayersByPointsAndName)
{[...game.players].sort(comparePlayersByPointsAndAnswerTime)
.slice(first, last)
.map((player, index) => {
let icon
Expand Down Expand Up @@ -90,10 +90,11 @@ const ScoreboardPage = ({game, page, pageSize}: ScoreboardPageProps) => {
<TableCell></TableCell>
<TableCell>Username</TableCell>
<TableCell align="right">Points</TableCell>
<TableCell align="right">ø</TableCell>
</TableRow>
</TableHead>
<TableBody>
{[...game.players].sort(comparePlayersByPointsAndName)
{[...game.players].sort(comparePlayersByPointsAndAnswerTime)
.slice(first, last)
.map((player, index) => {
const fontWeight = player.name === username ? 'bold' : 'normal'
Expand All @@ -106,6 +107,7 @@ const ScoreboardPage = ({game, page, pageSize}: ScoreboardPageProps) => {
</Typography>
</TableCell>
<TableCell width={10} align="right" sx={{fontWeight: fontWeight}}>{player.points}</TableCell>
<TableCell width={10} align="right" sx={{fontWeight: fontWeight}}>{Math.round(game.findAverageAnswerTime(player.id)/10)/100}s</TableCell>
</TableRow>
)
})}
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/services/GameCommandService.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,13 @@ export class GameCommandService {
}

public async answerQuestion(answer: AnswerQuestionCommand) {
await this.genericPost('/api/game/' + answer.gameId + '/answer-question', JSON.stringify(answer))
await this.genericPost('/api/game/' + answer.gameId + '/answer-question',
JSON.stringify({
gameId: answer.gameId,
gameQuestionId: answer.gameQuestionId,
answer: answer.answer,
answerTimestamp: getCurrentServerTime().toISOString()
}))
}

public async buzzQuestion(buzz: BuzzQuestionCommand) {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/services/GameEventTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export type QuestionAnsweredEvent = {
gamePlayerId: string,
playerAnswerId: string,
answer: string,
timeToAnswer: number
}

export type QuestionAnswerOverriddenEvent = {
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/services/GameRepository.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class GameRepository {

client?: Client;
currentGameState: Game | undefined
lastReceivedSeqNo: number = -1

public subscribeToGame(gameId: string, gameEventHandler: GameEventHandler) {
if (this.client == null) {
Expand Down Expand Up @@ -66,12 +67,18 @@ export class GameRepository {
this.client.deactivate()
this.client = undefined
this.currentGameState = undefined
this.lastReceivedSeqNo = -1
} else {
console.log("Client not active")
}
}

private handleEvent(wrappedEvent: GameEventWrapper, gameEventHandler: GameEventHandler) {
if (wrappedEvent.sequenceNumber <= this.lastReceivedSeqNo) {
console.log("Ignoring event with SeqNo ", wrappedEvent.sequenceNumber)
return
}
this.lastReceivedSeqNo = wrappedEvent.sequenceNumber
this.currentGameState = this.currentGameState!.onGameEvent(wrappedEvent.payload, wrappedEvent.eventType)
gameEventHandler.onGameEvent(wrappedEvent.payload, wrappedEvent.eventType, this.currentGameState)
}
Expand Down

0 comments on commit efb7654

Please sign in to comment.