Skip to content

Commit 82c98d6

Browse files
committed
feat(scoring): dynamic DB record-based bracket scoring for race mode
Replace static per-map reference durations with a live record sourced from the database. The first player to finish any (cup, map) combination sets the record and receives DIAMOND; every subsequent finisher is classified relative to the current in-session best via MedalBrackets.classify(). A faster finish breaks the record and also earns DIAMOND. - Add V4 Flyway migration and MapRecordEntity for the map_records table - Add MapRecordRepository (getRecordTime / saveOrUpdateRecord UPSERT) - Expose MapRecordRepository through DatabaseService / DatabaseServiceImpl - Introduce MapRecordComponent (ECS component on game entity, holds recordTimeMs) - Remove referenceDurationMs from MapDefinition; update BracketConfigComponent to hold only brackets (reference now comes from MapRecordComponent) - Rewrite CompletionDetectionSystem with optional MapRecordRepository injection: sets/updates MapRecordComponent on game entity and fires async DB UPSERT - GameOrchestrator.loadNextMap() seeds MapRecordComponent from DB async; passes MapRecordRepository to CompletionDetectionSystem - ScoreDisplaySystem.computePace() reads MapRecordComponent for the pace projection; returns DIAMOND when no record exists yet - Update ScoringServiceTest to seed MapRecordComponent instead of using the removed BracketConfigComponent reference field
1 parent 307b3fa commit 82c98d6

14 files changed

Lines changed: 391 additions & 113 deletions

File tree

server/src/main/java/net/elytrarace/server/cup/MapDefinition.java

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,49 +9,31 @@
99

1010
/**
1111
* Defines a single map within a cup, including its world location, ring checkpoints,
12-
* player spawn position, per-map boost configuration, optional guide points for spline
13-
* path visualization, and a reference completion time used to calibrate medal brackets.
12+
* player spawn position, per-map boost configuration, and optional guide points for
13+
* spline path visualization.
1414
*
15-
* @param name the display name of the map
16-
* @param worldDirectory the path to the world directory on disk
17-
* @param rings the ordered list of ring checkpoints players must fly through
18-
* @param spawnPos the position where players spawn at the start of this map
19-
* @param boostConfig firework boost settings for this map; defaults to {@link BoostConfig#DEFAULT}
20-
* @param guidePoints optional guide points for shaping the ideal racing line between rings
21-
* @param referenceDurationMs the target completion time in milliseconds used as the
22-
* anchor for medal brackets (diamond/gold/silver/bronze).
23-
* Defaults to {@link #DEFAULT_REFERENCE_DURATION_MS} (3 minutes),
24-
* which covers a typical map at moderate skill.
15+
* @param name the display name of the map
16+
* @param worldDirectory the path to the world directory on disk
17+
* @param rings the ordered list of ring checkpoints players must fly through
18+
* @param spawnPos the position where players spawn at the start of this map
19+
* @param boostConfig firework boost settings for this map; defaults to {@link BoostConfig#DEFAULT}
20+
* @param guidePoints optional guide points for shaping the ideal racing line between rings
2521
*/
2622
public record MapDefinition(String name, Path worldDirectory, List<Ring> rings, Pos spawnPos,
27-
BoostConfig boostConfig, List<GuidePointDTO> guidePoints,
28-
long referenceDurationMs) {
29-
30-
/** Default reference completion time used when the map JSON omits the field (3 minutes). */
31-
public static final long DEFAULT_REFERENCE_DURATION_MS = 180_000L;
23+
BoostConfig boostConfig, List<GuidePointDTO> guidePoints) {
3224

3325
public MapDefinition {
3426
rings = List.copyOf(rings);
3527
guidePoints = List.copyOf(guidePoints);
36-
if (referenceDurationMs <= 0) {
37-
throw new IllegalArgumentException(
38-
"referenceDurationMs must be positive: " + referenceDurationMs);
39-
}
4028
}
4129

42-
/** Convenience constructor using the default boost configuration and reference duration. */
30+
/** Convenience constructor using the default boost configuration and no guide points. */
4331
public MapDefinition(String name, Path worldDirectory, List<Ring> rings, Pos spawnPos) {
44-
this(name, worldDirectory, rings, spawnPos, BoostConfig.DEFAULT, List.of(), DEFAULT_REFERENCE_DURATION_MS);
32+
this(name, worldDirectory, rings, spawnPos, BoostConfig.DEFAULT, List.of());
4533
}
4634

47-
/** Convenience constructor with boost config, no guide points, default reference duration. */
35+
/** Convenience constructor with boost config and no guide points. */
4836
public MapDefinition(String name, Path worldDirectory, List<Ring> rings, Pos spawnPos, BoostConfig boostConfig) {
49-
this(name, worldDirectory, rings, spawnPos, boostConfig, List.of(), DEFAULT_REFERENCE_DURATION_MS);
50-
}
51-
52-
/** Convenience constructor that defaults the reference duration. */
53-
public MapDefinition(String name, Path worldDirectory, List<Ring> rings, Pos spawnPos,
54-
BoostConfig boostConfig, List<GuidePointDTO> guidePoints) {
55-
this(name, worldDirectory, rings, spawnPos, boostConfig, guidePoints, DEFAULT_REFERENCE_DURATION_MS);
37+
this(name, worldDirectory, rings, spawnPos, boostConfig, List.of());
5638
}
5739
}

server/src/main/java/net/elytrarace/server/ecs/component/BracketConfigComponent.java

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,13 @@
33
import net.elytrarace.common.ecs.Component;
44
import net.elytrarace.common.game.scoring.MedalBrackets;
55

6-
import java.time.Duration;
7-
86
/**
9-
* Holds the medal bracket configuration and the reference duration used to
10-
* classify a player's completion time into a {@link net.elytrarace.common.game.scoring.MedalTier}.
7+
* Holds the medal bracket configuration used to classify a player's completion
8+
* time into a {@link net.elytrarace.common.game.scoring.MedalTier}.
119
* <p>
12-
* Attached to the game entity only. Replaced by {@code GameOrchestrator.loadNextMap()}
13-
* on each new map using {@link MedalBrackets#DEFAULT} and the map's
14-
* {@code referenceDurationMs()}.
10+
* The reference time is no longer stored here — it comes from {@link MapRecordComponent}
11+
* (populated from DB at map load or set in-session by the first finisher).
12+
* Attached to the game entity only.
1513
*/
16-
public record BracketConfigComponent(MedalBrackets brackets, Duration reference) implements Component {
14+
public record BracketConfigComponent(MedalBrackets brackets) implements Component {
1715
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package net.elytrarace.server.ecs.component;
2+
3+
import net.elytrarace.common.ecs.Component;
4+
5+
/**
6+
* Holds the current best finish time for the active map, in milliseconds.
7+
* <p>
8+
* Set on the game entity by {@code GameOrchestrator.loadNextMap()} from the DB record
9+
* (if one exists) and updated in-session by {@code CompletionDetectionSystem} whenever
10+
* a player finishes faster than the current value. Absent when no record has ever been
11+
* set for this (cup, map) combination.
12+
*/
13+
public record MapRecordComponent(long recordTimeMs) implements Component {
14+
}
Lines changed: 106 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package net.elytrarace.server.ecs.system;
22

3+
import net.elytrarace.api.database.repository.MapRecordRepository;
34
import net.elytrarace.common.ecs.Component;
45
import net.elytrarace.common.ecs.Entity;
56
import net.elytrarace.common.ecs.EntityManager;
@@ -9,9 +10,11 @@
910
import net.elytrarace.server.cup.MapDefinition;
1011
import net.elytrarace.server.ecs.component.ActiveMapComponent;
1112
import net.elytrarace.server.ecs.component.BracketConfigComponent;
13+
import net.elytrarace.server.ecs.component.CupProgressComponent;
1214
import net.elytrarace.server.ecs.component.ElapsedTimeComponent;
1315
import net.elytrarace.server.ecs.component.ElytraFlightComponent;
1416
import net.elytrarace.server.ecs.component.GameModeComponent;
17+
import net.elytrarace.server.ecs.component.MapRecordComponent;
1518
import net.elytrarace.server.ecs.component.PlayerRefComponent;
1619
import net.elytrarace.server.ecs.component.RingTrackerComponent;
1720
import net.elytrarace.server.ecs.component.ScoreComponent;
@@ -21,36 +24,51 @@
2124

2225
import java.time.Duration;
2326
import java.util.Set;
27+
import java.util.UUID;
2428

2529
/**
2630
* Detects when a player has completed the active map (passed every ring) and
2731
* stamps their {@link ScoreComponent} with the completion time, medal tier, and
28-
* RACE-mode bracket bonus.
32+
* bracket bonus.
33+
* <p>
34+
* Scoring is record-relative:
35+
* <ul>
36+
* <li>The first player to ever finish a (cup, map) combination becomes the record holder
37+
* and receives DIAMOND regardless of their absolute time.</li>
38+
* <li>Any subsequent finisher faster than the current in-session record also receives
39+
* DIAMOND and becomes the new in-session record holder.</li>
40+
* <li>All other finishers are classified relative to the current record via
41+
* {@link MedalBrackets#classify(Duration, Duration)}.</li>
42+
* </ul>
43+
* The in-session record is stored in {@link MapRecordComponent} on the game entity.
44+
* When {@link MapRecordRepository} is available, each finish triggers an async UPSERT
45+
* (only persisted if the time is faster than the existing DB record).
2946
* <p>
3047
* The system is idempotent: it short-circuits once {@link ScoreComponent#hasFinished()}
31-
* is true, so running it multiple ticks per player has no observable effect.
32-
* It also degrades gracefully if optional game-entity components
33-
* ({@link ElapsedTimeComponent}, {@link BracketConfigComponent},
34-
* {@link GameModeComponent}) are missing, falling back to safe defaults and
35-
* logging at WARN level.
48+
* is true.
3649
*/
3750
public class CompletionDetectionSystem implements net.elytrarace.common.ecs.System {
3851

3952
private static final Logger LOGGER = LoggerFactory.getLogger(CompletionDetectionSystem.class);
4053

4154
private static final int POINTS_DIAMOND = 60;
42-
private static final int POINTS_GOLD = 45;
43-
private static final int POINTS_SILVER = 30;
44-
private static final int POINTS_BRONZE = 15;
45-
private static final int POINTS_FINISH = 5;
46-
private static final int POINTS_DNF = 0;
47-
48-
private static final Duration FALLBACK_REFERENCE = Duration.ofMinutes(3);
55+
private static final int POINTS_GOLD = 45;
56+
private static final int POINTS_SILVER = 30;
57+
private static final int POINTS_BRONZE = 15;
58+
private static final int POINTS_FINISH = 5;
59+
private static final int POINTS_DNF = 0;
4960

5061
private final EntityManager entityManager;
62+
private final @Nullable MapRecordRepository mapRecordRepository;
5163

5264
public CompletionDetectionSystem(EntityManager entityManager) {
65+
this(entityManager, null);
66+
}
67+
68+
public CompletionDetectionSystem(EntityManager entityManager,
69+
@Nullable MapRecordRepository mapRecordRepository) {
5370
this.entityManager = entityManager;
71+
this.mapRecordRepository = mapRecordRepository;
5472
}
5573

5674
@Override
@@ -60,60 +78,50 @@ public Set<Class<? extends Component>> getRequiredComponents() {
6078

6179
@Override
6280
public void process(Entity entity, float deltaTime) {
63-
// If the entity carries flight info, require active flight to count completion.
64-
// Players still on the ground or pre-launch should not finish a race.
65-
if (entity.hasComponent(ElytraFlightComponent.class)) {
66-
ElytraFlightComponent flight = entity.getComponent(ElytraFlightComponent.class);
67-
if (!flight.isFlying()) {
68-
return;
69-
}
81+
if (entity.hasComponent(ElytraFlightComponent.class)
82+
&& !entity.getComponent(ElytraFlightComponent.class).isFlying()) {
83+
return;
7084
}
7185

7286
var playerRef = entity.getComponent(PlayerRefComponent.class);
73-
var tracker = entity.getComponent(RingTrackerComponent.class);
74-
var score = entity.getComponent(ScoreComponent.class);
87+
var tracker = entity.getComponent(RingTrackerComponent.class);
88+
var score = entity.getComponent(ScoreComponent.class);
7589

7690
ActiveMapComponent activeMap = findGameComponent(ActiveMapComponent.class);
77-
if (activeMap == null) {
78-
return;
79-
}
91+
if (activeMap == null) return;
8092
MapDefinition map = activeMap.getCurrentMap();
81-
if (map == null) {
82-
return;
83-
}
93+
if (map == null) return;
8494
int totalRings = map.rings().size();
85-
if (totalRings == 0) {
86-
return;
87-
}
95+
if (totalRings == 0) return;
8896

89-
if (tracker.passedCount() < totalRings) {
90-
return;
91-
}
92-
if (score.hasFinished()) {
93-
return;
94-
}
97+
if (tracker.passedCount() < totalRings) return;
98+
if (score.hasFinished()) return;
9599

96-
// Player just finished this tick.
97100
long elapsedMs = readElapsedMs();
98-
BracketConfigComponent config = findGameComponent(BracketConfigComponent.class);
99-
MedalBrackets brackets;
100-
Duration reference;
101-
if (config == null) {
102-
LOGGER.warn("No BracketConfigComponent on game entity — falling back to MedalBrackets.DEFAULT and {}",
103-
FALLBACK_REFERENCE);
104-
brackets = MedalBrackets.DEFAULT;
105-
reference = FALLBACK_REFERENCE;
101+
MedalBrackets brackets = readBrackets();
102+
103+
// Classify relative to current record (or award DIAMOND to first finisher).
104+
Entity gameEntity = findGameEntityWithActiveMap();
105+
MapRecordComponent currentRecord = gameEntity != null
106+
? gameEntity.getComponent(MapRecordComponent.class) : null;
107+
108+
MedalTier tier;
109+
if (currentRecord == null || elapsedMs <= currentRecord.recordTimeMs()) {
110+
// First finisher or new record — always DIAMOND
111+
tier = MedalTier.DIAMOND;
112+
if (gameEntity != null) {
113+
gameEntity.addComponent(new MapRecordComponent(elapsedMs));
114+
}
106115
} else {
107-
brackets = config.brackets();
108-
reference = config.reference();
116+
tier = brackets.classify(
117+
Duration.ofMillis(elapsedMs),
118+
Duration.ofMillis(currentRecord.recordTimeMs()));
109119
}
110120

111-
GameMode mode = readGameMode();
112-
113121
score.setCompletionTimeMs(elapsedMs);
114-
MedalTier tier = brackets.classify(Duration.ofMillis(elapsedMs), reference);
115122
score.setMedalTier(tier);
116123

124+
GameMode mode = readGameMode();
117125
int bracketPts = 0;
118126
if (mode == GameMode.RACE) {
119127
bracketPts = bracketPointsFor(tier);
@@ -122,6 +130,20 @@ public void process(Entity entity, float deltaTime) {
122130

123131
LOGGER.info("Player {} completed the map in {} ms — {} (bracket points: {})",
124132
playerRef.getPlayer().getUsername(), elapsedMs, tier, bracketPts);
133+
134+
persistRecord(playerRef.getPlayerId(), elapsedMs);
135+
}
136+
137+
private void persistRecord(UUID holderId, long elapsedMs) {
138+
if (mapRecordRepository == null) return;
139+
String cupName = readCupName();
140+
String mapName = readMapName();
141+
if (cupName == null || mapName == null) return;
142+
mapRecordRepository.saveOrUpdateRecord(cupName, mapName, holderId, elapsedMs)
143+
.exceptionally(ex -> {
144+
LOGGER.warn("Failed to persist map record for ({}, {}): {}", cupName, mapName, ex.getMessage());
145+
return null;
146+
});
125147
}
126148

127149
private long readElapsedMs() {
@@ -133,6 +155,11 @@ private long readElapsedMs() {
133155
return elapsed.elapsedMs();
134156
}
135157

158+
private MedalBrackets readBrackets() {
159+
BracketConfigComponent config = findGameComponent(BracketConfigComponent.class);
160+
return config != null ? config.brackets() : MedalBrackets.DEFAULT;
161+
}
162+
136163
private GameMode readGameMode() {
137164
GameModeComponent modeComp = findGameComponent(GameModeComponent.class);
138165
if (modeComp == null) {
@@ -142,10 +169,30 @@ private GameMode readGameMode() {
142169
return modeComp.mode();
143170
}
144171

172+
private @Nullable String readCupName() {
173+
CupProgressComponent prog = findGameComponent(CupProgressComponent.class);
174+
return prog != null ? prog.getCup().name() : null;
175+
}
176+
177+
private @Nullable String readMapName() {
178+
ActiveMapComponent activeMap = findGameComponent(ActiveMapComponent.class);
179+
if (activeMap == null || activeMap.getCurrentMap() == null) return null;
180+
return activeMap.getCurrentMap().name();
181+
}
182+
145183
private <T extends Component> @Nullable T findGameComponent(Class<T> componentClass) {
146-
for (Entity gameEntity : entityManager.getEntities()) {
147-
if (gameEntity.hasComponent(componentClass)) {
148-
return gameEntity.getComponent(componentClass);
184+
for (Entity e : entityManager.getEntities()) {
185+
if (e.hasComponent(componentClass)) {
186+
return e.getComponent(componentClass);
187+
}
188+
}
189+
return null;
190+
}
191+
192+
private @Nullable Entity findGameEntityWithActiveMap() {
193+
for (Entity e : entityManager.getEntities()) {
194+
if (e.hasComponent(ActiveMapComponent.class)) {
195+
return e;
149196
}
150197
}
151198
return null;
@@ -154,11 +201,11 @@ private GameMode readGameMode() {
154201
private static int bracketPointsFor(MedalTier tier) {
155202
return switch (tier) {
156203
case DIAMOND -> POINTS_DIAMOND;
157-
case GOLD -> POINTS_GOLD;
158-
case SILVER -> POINTS_SILVER;
159-
case BRONZE -> POINTS_BRONZE;
160-
case FINISH -> POINTS_FINISH;
161-
case DNF -> POINTS_DNF;
204+
case GOLD -> POINTS_GOLD;
205+
case SILVER -> POINTS_SILVER;
206+
case BRONZE -> POINTS_BRONZE;
207+
case FINISH -> POINTS_FINISH;
208+
case DNF -> POINTS_DNF;
162209
};
163210
}
164211
}

0 commit comments

Comments
 (0)