Skip to content

Commit bdf3a24

Browse files
authored
feat(scoring): Zeit-Bracket scoring strategy for Race Mode (#168) (#178)
* feat(scoring): implement Zeit-Bracket scoring strategy for Race Mode (#168) Replaces the old ScoringService/ScoringServiceImpl with a sealed ScoringStrategy hierarchy. RaceScoringStrategy applies time-bracket points (DIAMOND=60, GOLD=45, SILVER=30, BRONZE=15, FINISH=5) on map completion, then position bonuses (1st=10, 2nd=6, 3rd=3, 4th+=1) at end-phase. PracticeScoringStrategy classifies medal tiers only with no competitive points. Strategy is chosen by ScoringStrategyFactory based on GameMode and stored as ScoringStrategyComponent on the game entity. MapDefinition gains referenceDurationMs (default 3 min) used as the bracket classification anchor. PlayerScore and ScoreComponent gain completionTimeMs and medalTier fields. Flyway V1 baseline + V2 migration add completion_time_ms to game_results; database module switches to Flyway for all future schema changes per ADR-0011. * refactor(scoring): migrate to pure ECS — remove ScoringStrategy hierarchy Replaces the hybrid ScoringStrategy service pattern with pure ECS: - NEW: ElapsedTimeComponent (game entity, refreshed per tick by phase) - NEW: BracketConfigComponent (game entity, set per map by orchestrator) - NEW: CompletionDetectionSystem — detects when a player passes all rings, writes completionTimeMs + medalTier + bracket points (RACE: 60/45/30/15/5) directly into ScoreComponent; idempotent, degrades gracefully on missing config - ScoreComponent.addPositionBonus(int) added for layered bonus application - MinestomGamePhase: updateElapsedTime() runs before entityManager.update() so all systems see a consistent elapsed-time value on every tick; recordCompletions() removed (now CompletionDetectionSystem's job) - MinestomEndPhase.rankAndApplyBonuses() is now pure ECS — reads GameModeComponent, applies 10/6/3/1 position bonuses for RACE, no-op for PRACTICE - GameOrchestrator registers CompletionDetectionSystem in the system chain and attaches BracketConfigComponent to the game entity on each map load - GameSession: ScoringStrategy field removed, constructor simplified to 3 args - DELETED: ScoringStrategy, BaseScoringStrategy, RaceScoringStrategy, PracticeScoringStrategy, ScoringStrategyFactory, ScoringStrategyComponent - Tests: ScoringServiceTest rewritten as CompletionDetectionSystem @envtest covering all bracket tiers, PRACTICE mode, idempotency, and DNF handling * test(phase): fix MinestomEndPhaseRankingTest — update position bonuses to 10/6/3/1 and use @envtest with real players * fix(database): V3 migration — convert UUID columns from BINARY(16) to native uuid type Hibernate ORM 7 maps java.util.UUID to MariaDB's native uuid type, but the V1 baseline created elytra_players.playerId and game_results.player_id as BINARY(16). This caused two runtime errors: - hbm2ddl=update tried to ALTER the columns but MariaDB refused due to FK - INSERT failed with 'Data too long' because Hibernate sends UUID as a 36-char string, not 16-byte binary V3 drops fk_game_results_player, converts both columns to uuid NOT NULL, and re-adds the FK. Safe on fresh installs too (no-op if already uuid). * feat(hud): replace speed+points display with speed, ring progress, elapsed time and bracket pace - Actionbar during race: '{speed} m/s · {passed}/{total} · {mm:ss.t} [BRACKET]' where bracket is colored (aqua=DIAMOND, gold=GOLD, gray=SILVER, bronze=BRONZE, red=FINISH) and projected from current pace (elapsedMs / passed * total) - After finish: '{speed} m/s · MEDAL · {finish time}' - Ring pass feedback: 'Ring X/N!' instead of '+1 pts' — ring points no longer meaningful to show per-ring since bracket score is awarded at finish - ScoreDisplaySystem now injects EntityManager to read ElapsedTimeComponent, BracketConfigComponent and ActiveMapComponent from the game entity - Updated translation keys: hud.actionbar (4 args), hud.actionbar.finished, hud.ring_passed (ring count instead of points) * fix(game): reset rings and score on landing, sync isFlying from player.isGliding() - ElytraPhysicsSystem now syncs flight.setFlying(player.isGliding()) every tick so landing is detected correctly (previously setFlying(false) was never called) - New LandingResetSystem: on flying→notFlying transition for unfinished players, resets RingTrackerComponent + ScoreComponent and clears the actionbar (runs after ElytraPhysicsSystem, before ScoreDisplaySystem) - ScoreDisplaySystem clears its tick/hash cache when not flying so the next takeoff renders the HUD immediately without a stale-hash delay - HudComponent.clearActionbar() sends Component.empty() to dismiss the last message * 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 * fix(server): resolve two compile errors blocking CI - GameOrchestrator: remove duplicate local 'cupName' declaration inside loadNextMap() lambda (variable already declared at top of block) - ElytraPhysicsSystem: replace player.isGliding() (not available in Minestom) with player.isOnGround() as the landing detection proxy; when the server confirms ground contact, flight.setFlying(false) is set so LandingResetSystem can detect the flying→landed transition
1 parent 7da668b commit bdf3a24

41 files changed

Lines changed: 1652 additions & 364 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,20 @@
1919
* @param boostConfig firework boost settings for this map; defaults to {@link BoostConfig#DEFAULT}
2020
* @param guidePoints optional guide points for shaping the ideal racing line between rings
2121
*/
22-
public record MapDefinition(String name, Path worldDirectory, List<Ring> rings, Pos spawnPos, BoostConfig boostConfig, List<GuidePointDTO> guidePoints) {
22+
public record MapDefinition(String name, Path worldDirectory, List<Ring> rings, Pos spawnPos,
23+
BoostConfig boostConfig, List<GuidePointDTO> guidePoints) {
24+
2325
public MapDefinition {
2426
rings = List.copyOf(rings);
2527
guidePoints = List.copyOf(guidePoints);
2628
}
2729

28-
/** Convenience constructor using the default boost configuration. */
30+
/** Convenience constructor using the default boost configuration and no guide points. */
2931
public MapDefinition(String name, Path worldDirectory, List<Ring> rings, Pos spawnPos) {
3032
this(name, worldDirectory, rings, spawnPos, BoostConfig.DEFAULT, List.of());
3133
}
3234

33-
/** Convenience constructor with boost config but no guide points. */
35+
/** Convenience constructor with boost config and no guide points. */
3436
public MapDefinition(String name, Path worldDirectory, List<Ring> rings, Pos spawnPos, BoostConfig boostConfig) {
3537
this(name, worldDirectory, rings, spawnPos, boostConfig, List.of());
3638
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package net.elytrarace.server.ecs.component;
2+
3+
import net.elytrarace.common.ecs.Component;
4+
import net.elytrarace.common.game.scoring.MedalBrackets;
5+
6+
/**
7+
* Holds the medal bracket configuration used to classify a player's completion
8+
* time into a {@link net.elytrarace.common.game.scoring.MedalTier}.
9+
* <p>
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.
13+
*/
14+
public record BracketConfigComponent(MedalBrackets brackets) implements Component {
15+
}
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+
* Tracks how long the current race has been running, in milliseconds.
7+
* <p>
8+
* Attached to the game entity only. Updated every tick by
9+
* {@code MinestomGamePhase} before {@code entityManager.update()} runs, so any
10+
* system processing player entities sees a consistent elapsed time for the
11+
* current tick.
12+
*/
13+
public record ElapsedTimeComponent(long elapsedMs) implements Component {
14+
}

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

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package net.elytrarace.server.ecs.component;
22

33
import net.elytrarace.common.ecs.Component;
4+
import net.elytrarace.common.game.scoring.MedalTier;
45
import net.kyori.adventure.bossbar.BossBar;
56
import net.kyori.adventure.sound.Sound;
7+
import net.kyori.adventure.text.TextComponent;
68
import net.kyori.adventure.text.format.NamedTextColor;
9+
import net.kyori.adventure.text.format.TextColor;
710
import net.kyori.adventure.text.format.TextDecoration;
811
import net.kyori.adventure.title.Title;
912
import net.minestom.server.entity.Player;
@@ -23,6 +26,8 @@
2326
*/
2427
public class HudComponent implements Component {
2528

29+
private static final TextColor BRONZE_COLOR = TextColor.color(0xCD7F32);
30+
2631
private final Player player;
2732
private BossBar cupProgressBar;
2833

@@ -31,22 +36,48 @@ public HudComponent(Player player) {
3136
}
3237

3338
/**
34-
* Sends the speed/score actionbar line.
39+
* Sends the live race actionbar: speed, ring progress, elapsed time, and the
40+
* bracket tier the player is currently on pace for (projected finish bracket).
3541
*
36-
* @param speedBlocksPerSec current speed in blocks per second
37-
* @param currentPoints accumulated ring points
42+
* @param speedBlocksPerSec current speed in blocks/s
43+
* @param passed rings passed so far
44+
* @param total total rings on this map
45+
* @param elapsedMs race time elapsed in milliseconds
46+
* @param pace projected bracket if the player finished right now
3847
*/
39-
public void updateActionbar(double speedBlocksPerSec, int currentPoints) {
48+
public void updateActionbar(double speedBlocksPerSec, int passed, int total,
49+
long elapsedMs, MedalTier pace) {
50+
net.kyori.adventure.text.Component timeAndPace = buildTimeAndPace(elapsedMs, pace);
4051
player.sendActionBar(net.kyori.adventure.text.Component.translatable(
4152
"hud.actionbar",
4253
net.kyori.adventure.text.Component.text(String.format("%.1f", speedBlocksPerSec)),
43-
net.kyori.adventure.text.Component.text(currentPoints)));
54+
net.kyori.adventure.text.Component.text(passed),
55+
net.kyori.adventure.text.Component.text(total),
56+
timeAndPace));
57+
}
58+
59+
/**
60+
* Sends the post-finish actionbar: speed, medal tier earned, and finish time.
61+
* Replaces the race actionbar once {@link ScoreComponent#hasFinished()} is true.
62+
*
63+
* @param speedBlocksPerSec current speed in blocks/s
64+
* @param medal medal tier earned on this map
65+
* @param finishMs map completion time in milliseconds
66+
*/
67+
public void updateActionbarFinished(double speedBlocksPerSec, MedalTier medal, long finishMs) {
68+
net.kyori.adventure.text.Component medalComponent = net.kyori.adventure.text.Component
69+
.text(medal.name(), tierColor(medal), TextDecoration.BOLD);
70+
player.sendActionBar(net.kyori.adventure.text.Component.translatable(
71+
"hud.actionbar.finished",
72+
net.kyori.adventure.text.Component.text(String.format("%.1f", speedBlocksPerSec)),
73+
medalComponent,
74+
net.kyori.adventure.text.Component.text(formatTime(finishMs))));
4475
}
4576

4677
/**
4778
* Shows or replaces the cup-progress boss bar.
4879
*
49-
* @param cupName current cup name
80+
* @param cupName current cup name
5081
* @param currentMap 1-based map index
5182
* @param totalMaps total maps in the cup
5283
*/
@@ -109,26 +140,55 @@ public void showCountdown(int seconds) {
109140
}
110141

111142
/**
112-
* Shows ring-pass feedback: green "+N" actionbar and experience-orb sound.
143+
* Shows ring-pass feedback: "Ring X/N!" in the actionbar and a pickup sound.
113144
*
114-
* @param points points awarded for the ring
145+
* @param passed rings passed so far (including this one)
146+
* @param total total rings on the map
115147
*/
116-
public void showRingPassed(int points) {
148+
public void showRingPassed(int passed, int total) {
117149
player.sendActionBar(net.kyori.adventure.text.Component.translatable(
118150
"hud.ring_passed",
119-
net.kyori.adventure.text.Component.text(points)));
151+
net.kyori.adventure.text.Component.text(passed),
152+
net.kyori.adventure.text.Component.text(total)));
120153
player.playSound(Sound.sound(
121154
SoundEvent.ENTITY_EXPERIENCE_ORB_PICKUP,
122155
Sound.Source.MASTER,
123156
1.0f,
124157
1.5f));
125158
}
126159

160+
/** Clears the actionbar immediately (sends an empty component). */
161+
public void clearActionbar() {
162+
player.sendActionBar(net.kyori.adventure.text.Component.empty());
163+
}
164+
127165
/** Hides the boss bar. Call before removing the entity. */
128166
public void cleanup() {
129167
if (cupProgressBar != null) {
130168
player.hideBossBar(cupProgressBar);
131169
cupProgressBar = null;
132170
}
133171
}
172+
173+
private static net.kyori.adventure.text.Component buildTimeAndPace(long elapsedMs, MedalTier pace) {
174+
TextColor color = tierColor(pace);
175+
return net.kyori.adventure.text.Component.text(
176+
formatTime(elapsedMs) + " [" + pace.name() + "]", color);
177+
}
178+
179+
static TextColor tierColor(MedalTier tier) {
180+
return switch (tier) {
181+
case DIAMOND -> NamedTextColor.AQUA;
182+
case GOLD -> NamedTextColor.GOLD;
183+
case SILVER -> NamedTextColor.GRAY;
184+
case BRONZE -> BRONZE_COLOR;
185+
case FINISH, DNF -> NamedTextColor.RED;
186+
};
187+
}
188+
189+
static String formatTime(long elapsedMs) {
190+
long seconds = elapsedMs / 1000;
191+
long tenths = (elapsedMs % 1000) / 100;
192+
return String.format("%d:%02d.%d", seconds / 60, seconds % 60, tenths);
193+
}
134194
}
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+
}

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

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,33 @@
11
package net.elytrarace.server.ecs.component;
22

33
import net.elytrarace.common.ecs.Component;
4+
import net.elytrarace.common.game.scoring.MedalTier;
5+
import org.jetbrains.annotations.Nullable;
46

57
/**
6-
* Stores a player's score, split into ring points and position bonus.
8+
* Stores a player's per-map score state: ring points, position bonus, completion
9+
* time, and the resulting medal tier.
10+
* <p>
11+
* The fields are written by different actors:
12+
* <ul>
13+
* <li>{@code ringPoints} — incremented by {@link net.elytrarace.server.ecs.system.RingCollisionSystem}</li>
14+
* <li>{@code positionBonus} — set by {@link net.elytrarace.server.phase.MinestomEndPhase}
15+
* after ranking players by total score (RACE mode only)</li>
16+
* <li>{@code completionTimeMs} — set by {@code CompletionDetectionSystem} when the player
17+
* crosses the last ring</li>
18+
* <li>{@code medalTier} — set by {@code CompletionDetectionSystem} when the run is
19+
* classified against the map's bracket configuration</li>
20+
* </ul>
721
*/
822
public class ScoreComponent implements Component {
923

24+
/** Sentinel for a player that did not finish the current map. */
25+
public static final long NOT_FINISHED = -1L;
26+
1027
private int ringPoints;
1128
private int positionBonus;
29+
private long completionTimeMs = NOT_FINISHED;
30+
private @Nullable MedalTier medalTier;
1231

1332
/**
1433
* Returns the total score (ring points + position bonus).
@@ -33,11 +52,49 @@ public void setPositionBonus(int positionBonus) {
3352
this.positionBonus = positionBonus;
3453
}
3554

55+
public void addPositionBonus(int bonus) {
56+
this.positionBonus += bonus;
57+
}
58+
59+
/**
60+
* Returns the player's map completion time in milliseconds, or
61+
* {@link #NOT_FINISHED} if they have not finished yet.
62+
*/
63+
public long getCompletionTimeMs() {
64+
return completionTimeMs;
65+
}
66+
67+
public void setCompletionTimeMs(long completionTimeMs) {
68+
this.completionTimeMs = completionTimeMs;
69+
}
70+
71+
/**
72+
* Returns whether the player crossed the last ring on the current map.
73+
*/
74+
public boolean hasFinished() {
75+
return completionTimeMs >= 0;
76+
}
77+
78+
/**
79+
* Returns the medal tier earned on the current map, or {@code null} if the
80+
* map has not been classified yet.
81+
*/
82+
public @Nullable MedalTier getMedalTier() {
83+
return medalTier;
84+
}
85+
86+
public void setMedalTier(@Nullable MedalTier medalTier) {
87+
this.medalTier = medalTier;
88+
}
89+
3690
/**
37-
* Resets the score to zero.
91+
* Resets the score for a new map. Clears ring points, position bonus,
92+
* completion time, and medal tier.
3893
*/
3994
public void reset() {
4095
this.ringPoints = 0;
4196
this.positionBonus = 0;
97+
this.completionTimeMs = NOT_FINISHED;
98+
this.medalTier = null;
4299
}
43100
}

0 commit comments

Comments
 (0)