Skip to content

Commit 5664a63

Browse files
committed
GameStateValidator
1 parent 5cc7fde commit 5664a63

File tree

8 files changed

+164
-27
lines changed

8 files changed

+164
-27
lines changed
Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,83 @@
1+
from datetime import datetime, timezone
2+
from sqlalchemy.orm import Session
3+
4+
from app.gameConfig import PHASES_WITH_LEVELS
5+
from app.model.Level import Level
6+
from app.model.Participant import Participant
7+
from app.model.Phase import Phase
8+
from app.statistics3.StatsCircuit import StatsCircuit
9+
from app.statistics3.StatsParticipant import StatsParticipant
10+
from app.statistics3.StatsPhase import StatsPhase
11+
from app.statistics3.StatsPhaseLevels import StatsPhaseLevels
12+
from app.statistics3.statisticsUtils import TIME_TOLERANCE, LogValidationError
13+
114
class GameStateValidator:
215
"""
316
Ensure that the player logfile/statistic is plausible when compared to the last saved
417
game state in the [reversim.db](instance/statistics/reversim.db) player database.
518
"""
619

7-
# TODO
20+
def validate(self, participant: StatsParticipant, session: Session):
21+
22+
player = session.get_one(Participant, participant.pseudonym)
23+
24+
for i, stats_phase in enumerate(participant.phases):
25+
# The phase from the game state
26+
assert participant.phaseIdx is not None
27+
gamestate_phase = player.phases[i]
828

29+
self.validate_phase(stats_phase, gamestate_phase)
930

10-
def validate():
11-
12-
# The phase from the game state
13-
assert statsParticipant.phaseIdx is not None
14-
gamestate_phase = player.phases[statsParticipant.phaseIdx]
1531

32+
def validate_phase(self, stats_phase: StatsPhase, gamestate_phase: Phase):
1633
# Check that the phase type matches what was shown during the game
17-
if gamestate_phase.name != phaseType:
18-
raise LogValidationError(f'{phaseType} does not match the gamestate {gamestate_phase.name}')
34+
if gamestate_phase.name != stats_phase.phaseType:
35+
raise LogValidationError(f'{stats_phase.phaseType} does not match the gamestate {gamestate_phase.name}')
1936

2037
# Assert that a phase with levels really has levels
21-
assert phaseType in PHASES_WITH_LEVELS and len(gamestate_phase.levels) > 0, (
22-
f'Phase {phaseType} is expected to have no levels, but gameState has {len(gamestate_phase.levels)}'
23-
)
38+
if stats_phase.phaseType in PHASES_WITH_LEVELS:
39+
assert len(gamestate_phase.levels) > 0, (
40+
f'Phase {stats_phase.phaseType} is expected to have no levels, but gameState has {len(gamestate_phase.levels)}'
41+
)
2442

2543
# Assert that a phase without levels really has no levels
26-
assert phaseType not in PHASES_WITH_LEVELS and len(gamestate_phase.levels) < 1, (
27-
f'Phase {phaseType} is expected to have levels, but gameState has 0'
28-
)
44+
else:
45+
assert len(gamestate_phase.levels) < 1, (
46+
f'Phase {stats_phase.phaseType} is expected to have levels, but gameState has 0'
47+
)
48+
49+
if isinstance(stats_phase, StatsPhaseLevels):
50+
for i, stats_level in enumerate(stats_phase.levels):
51+
# We are only interested in slides with circuit
52+
if not isinstance(stats_level, StatsCircuit):
53+
continue
54+
55+
self.validate_level(stats_level, gamestate_phase.levels[i])
56+
57+
58+
def validate_level(self, stats_level: StatsCircuit, gamestate_level: Level):
59+
if stats_level.slide_type != gamestate_level.type:
60+
raise LogValidationError(f'Type {stats_level.slide_type}(stats) != {gamestate_level.type}(db)')
61+
62+
db_level_name = Level.uniformName(gamestate_level.fileName)
63+
db_level_start = datetime.fromtimestamp(gamestate_level.getStartTime()/1000, tz=timezone.utc)
64+
db_level_finish = datetime.fromtimestamp(gamestate_level.timeFinished/1000, tz=timezone.utc)
65+
66+
if stats_level.log_name != db_level_name:
67+
raise LogValidationError(f'Name {stats_level.log_name}(stats) != {db_level_name}(db)')
68+
69+
if stats_level.switchClicks != gamestate_level.switchClicks:
70+
raise LogValidationError(f'Switch {stats_level.switchClicks}(stats) != {gamestate_level.switchClicks}(db)')
71+
72+
if stats_level.confirmClicks != gamestate_level.confirmClicks:
73+
raise LogValidationError(f'Confirm {stats_level.confirmClicks}(stats) != {gamestate_level.confirmClicks}(db)')
74+
75+
if stats_level.time_start is not None:
76+
stats_start = stats_level.time_start.replace(tzinfo=timezone.utc)
77+
if (stats_start - db_level_start).total_seconds() > TIME_TOLERANCE:
78+
raise LogValidationError(f'Start Time {stats_start}(stats) != {db_level_start}(db)')
79+
80+
if stats_level.time_finish is not None:
81+
stats_finish = stats_level.time_finish.replace(tzinfo=timezone.utc)
82+
if (stats_finish - db_level_finish).total_seconds() > TIME_TOLERANCE:
83+
raise LogValidationError(f'Finish Time {stats_finish}(stats) != {db_level_finish}(db)')

app/statistics3/LogEventValidator.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,17 @@ def event_chrono(self,
191191

192192
else:
193193
raise LogValidationError(f'Unknown operation "{event.operation}"', event)
194+
195+
# Phase Time Limit Operations
196+
elif 'countdown' == event.timerType:
197+
if 'start' == event.operation:
198+
self.start_phase_countdown(participant, event)
199+
elif 'stop' == event.operation:
200+
self.stop_phase_countdown(participant, event)
201+
else:
202+
raise LogValidationError(f'Unknown timer operation {event.operation}')
203+
else:
204+
raise LogValidationError(f'Unknown timer type "{event.timerType}"')
194205

195206

196207
def event_quali(self,
@@ -328,13 +339,14 @@ def start_phase(self, event: ChronoEvent, statsParticipant: StatsParticipant):
328339
reload_location = statsParticipant.activePhase.phaseType + levelName
329340
logging.warning(f'Participant {ui} reloaded the page at "{reload_location}"')
330341
statsParticipant.reloads.append(reload_location)
331-
342+
332343
# If this is not a preload phase, start the phase as usual
333344
else:
334345
if event.timerName != statsParticipant.activePhase.phaseType:
335-
raise LogValidationError(f'Currently active is {statsParticipant.activePhase.phaseType} but log asks for {event.timerName}')
346+
if event.timerName == PhaseType.FinalScene:
347+
raise LogValidationError(f'Currently active is {statsParticipant.activePhase.phaseType} but log asks for {event.timerName}')
336348

337-
statsParticipant.activePhase.start(event.timeClient)
349+
statsParticipant.activePhase.start(time_start=event.timeClient, time_limit=event.limit)
338350

339351

340352
def load_slide(self, event: ChronoEvent, statsParticipant: StatsParticipant):
@@ -378,13 +390,32 @@ def stop_slide(self, event: ChronoEvent, statsParticipant: StatsParticipant):
378390
statsParticipant.activePhase.activeLevel.stop(event.timeClient)
379391

380392

393+
def start_phase_countdown(self, statsParticipant: StatsParticipant, event: ChronoEvent):
394+
assert event.timeClient is not None
395+
396+
statsParticipant.activePhase.start_phase_time_limit(event.timeClient)
397+
398+
399+
def stop_phase_countdown(self, statsParticipant: StatsParticipant, event: ChronoEvent):
400+
assert event.timeClient is not None
401+
402+
if not isinstance(statsParticipant.activePhase, StatsPhaseLevels):
403+
raise LogValidationError('Countdown event can only occur in phase with levels')
404+
405+
statsParticipant.activePhase.stop_phase_time_limit(event.timeClient)
406+
407+
381408
def click_continue(self,
382409
statsParticipant: StatsParticipant,
383410
event: ClickEvent
384411
):
385412
assert event.timeClient is not None
386413
assert event.object == ClickableObjects.CONTINUE
387414

415+
if statsParticipant.activePhase.phaseType == PhaseType.AltTask:
416+
logging.info('End of Phase AltTask')
417+
return
418+
388419
# If it is a level continue
389420
if isinstance(statsParticipant.activePhase, StatsPhaseLevels):
390421
activeLevel = statsParticipant.activePhase.activeLevel

app/statistics3/StatsCircuit.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def click_confirm(self, time_finish: TIMESTAMP_MS, solved: bool):
2727
if self.status != CurrentLevelState.STARTED:
2828
assert self.time_finish is not None
2929
if (
30-
self.status != CurrentLevelState.FINISHED or
30+
self.status not in [CurrentLevelState.FINISHED, CurrentLevelState.TIMEOUT] or
3131
(time_finish - self.time_finish).total_seconds() > TIME_TOLERANCE
3232
):
3333
raise LogValidationError(f'Cannot click confirm in {self.slide_type} with status {self.status}')
@@ -41,7 +41,7 @@ def click_continue(self, time_finish: TIMESTAMP_MS):
4141
self.status not in [CurrentLevelState.FINISHED, CurrentLevelState.SKIPPED, CurrentLevelState.TIMEOUT] or
4242
self.time_finish is None
4343
):
44-
raise LogValidationError(f'Level status is {self.status}, time_finish={self.time_finish}')
44+
raise LogValidationError(f'Continue click on unfinished level, status={self.status}, time_finish={self.time_finish}')
4545

4646

4747
def skip(self, time_skip: TIMESTAMP_MS):

app/statistics3/StatsPhase.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11

2-
from datetime import timedelta
32
import logging
4-
from app.statistics3.statisticsUtils import TIME_TOLERANCE, TIMESTAMP_MS, CurrentState, LogValidationError
3+
from datetime import timedelta
4+
5+
from app.statistics3.statisticsUtils import (
6+
TIME_TOLERANCE,
7+
TIMESTAMP_MS,
8+
CurrentState,
9+
LogValidationError,
10+
)
511
from app.utilsGame import PhaseType
612

713

@@ -11,8 +17,9 @@ def __init__(self, type_phase: PhaseType, time_load: TIMESTAMP_MS) -> None:
1117
self.phaseType = type_phase
1218

1319
self.time_load: TIMESTAMP_MS = time_load
14-
self.time_start: TIMESTAMP_MS|None
15-
self.time_finish: TIMESTAMP_MS|None
20+
self.time_start: TIMESTAMP_MS|None = None
21+
self.time_start_levels: TIMESTAMP_MS|None = None
22+
self.time_finish: TIMESTAMP_MS|None = None
1623

1724
self.time_limit: timedelta|None = None
1825

@@ -55,3 +62,21 @@ def finish(self, time_finish: TIMESTAMP_MS):
5562
if recorded_duration > allowed_duration:
5663
logging.warning(f'Overtime {recorded_duration}, allowed was {allowed_duration} in {self.phaseType}')
5764
#raise LogValidationError(f'Overtime {recorded_duration}, allowed was {allowed_duration}')
65+
66+
67+
def start_phase_time_limit(self, time_start_levels: TIMESTAMP_MS):
68+
self.time_start_levels = time_start_levels
69+
70+
if self.status not in [CurrentState.LOADED, CurrentState.STARTED]:
71+
raise LogValidationError(f'Expected phase loaded, got {self.status}')
72+
73+
if self.time_start is not None and self.time_start > time_start_levels:
74+
raise LogValidationError('The time_start_levels is invalid')
75+
76+
77+
def stop_phase_time_limit(self, time_stop_levels: TIMESTAMP_MS):
78+
if self.status != CurrentState.STARTED:
79+
raise LogValidationError(f'Expected phase started, got {self.status}')
80+
81+
self.finish(time_stop_levels)
82+
self.status = CurrentState.TIMEOUT

app/statistics3/StatsPhaseLevels.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
from typing import override
2+
13
from app.gameConfig import LEVEL_FILETYPES_WITH_TASK, PHASES_WITH_LEVELS
4+
from app.statistics3.statisticsUtils import TIMESTAMP_MS, CurrentLevelState, LogValidationError
25
from app.statistics3.StatsCircuit import StatsCircuit
36
from app.statistics3.StatsPhase import StatsPhase
47
from app.statistics3.StatsSlide import StatsSlide
5-
from app.statistics3.statisticsUtils import TIMESTAMP_MS, LogValidationError
68
from app.utilsGame import LevelType, PhaseType
79

810

@@ -20,8 +22,8 @@ def __init__(self, type_phase: PhaseType, time_load: TIMESTAMP_MS) -> None:
2022

2123
self.levels: list[StatsSlide] = []
2224
self.levelIdx: int = -1
23-
2425

26+
2527
def load_level(self, type_level: str, log_name: str, time_load: TIMESTAMP_MS):
2628
try:
2729
levelType = LevelType(type_level)
@@ -35,3 +37,13 @@ def load_level(self, type_level: str, log_name: str, time_load: TIMESTAMP_MS):
3537

3638
self.levels.append(level)
3739
self.levelIdx = len(self.levels) - 1
40+
41+
42+
@override
43+
def stop_phase_time_limit(self, time_stop_levels: TIMESTAMP_MS):
44+
# If the level was not already solved, mark it as timeout
45+
if self.activeLevel.status != CurrentLevelState.FINISHED:
46+
self.activeLevel.timeout(time_stop_levels)
47+
48+
return super().stop_phase_time_limit(time_stop_levels)
49+

app/statistics3/StatsSlide.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ def start(self, time_start: TIMESTAMP_MS, time_limit: float|None):
5050

5151

5252
def stop(self, time_finish: TIMESTAMP_MS):
53+
if self.status == CurrentLevelState.TIMEOUT:
54+
logging.debug('Stop after level timeouted')
55+
assert self.time_finish is not None
56+
return
57+
5358
if self.status != CurrentLevelState.STARTED:
5459
raise LogValidationError(f'Cannot finish {self.slide_type} with status {self.status}')
5560
assert self.time_start is not None, "Started means timestamp should have been set"
@@ -67,5 +72,10 @@ def stop(self, time_finish: TIMESTAMP_MS):
6772
#raise LogValidationError(f'Overtime {recorded_duration}, allowed was {allowed_duration}')
6873

6974

75+
def timeout(self, time_finish: TIMESTAMP_MS):
76+
self.stop(time_finish)
77+
self.status = CurrentLevelState.TIMEOUT
78+
79+
7080
def click_continue(self, time_finish: TIMESTAMP_MS):
7181
self.stop(time_finish)

app/statistics3/statistics3.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from app.model.LevelLoader.LevelLoader import LevelLoader
1313
from app.model.LogEvents import GroupAssignmentEvent, LogEvent
1414
from app.model.Participant import Participant
15+
from app.statistics3.GameStateValidator import GameStateValidator
1516
from app.statistics3.LogEventValidator import LogEventValidator
1617
from app.statistics3.statisticsUtils import LogValidationError
1718
from app.statistics3.StatsParticipant import StatsParticipant
@@ -80,6 +81,7 @@ def read_participant(self, session: Session, pseudonym: str) -> StatsParticipant
8081
).scalars()
8182

8283
log_validator = LogEventValidator()
84+
state_validator = GameStateValidator()
8385

8486
logging.info(f'Validating {getShortPseudo(pseudonym)}')
8587
for event in events:
@@ -91,6 +93,8 @@ def read_participant(self, session: Session, pseudonym: str) -> StatsParticipant
9193
e.event = event
9294
raise e
9395

96+
state_validator.validate(statsParticipant, session)
97+
9498
# If all went well, we have a populated player statistic
9599
return statsParticipant
96100

app/statistics3/statisticsUtils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
from app.model.LogEvents import LogEvent
55

6-
76
type TIMESTAMP_MS = datetime
87

98
TIME_TOLERANCE = 0.1 # seconds
@@ -27,6 +26,7 @@ class CurrentState(StrEnum):
2726
LOADED = 'Loaded'
2827
STARTED = 'In Progress'
2928
FINISHED = 'Finished'
29+
TIMEOUT = 'Timeout'
3030

3131

3232
class CurrentLevelState(StrEnum):
@@ -35,4 +35,4 @@ class CurrentLevelState(StrEnum):
3535
SOLVED = 'Solved'
3636
FINISHED = 'Finished'
3737
SKIPPED = 'Skipped'
38-
TIMEOUT = 'Timeout'
38+
TIMEOUT = 'Timeout'

0 commit comments

Comments
 (0)