11package net .elytrarace .server .ecs .system ;
22
3+ import net .elytrarace .api .database .repository .MapRecordRepository ;
34import net .elytrarace .common .ecs .Component ;
45import net .elytrarace .common .ecs .Entity ;
56import net .elytrarace .common .ecs .EntityManager ;
910import net .elytrarace .server .cup .MapDefinition ;
1011import net .elytrarace .server .ecs .component .ActiveMapComponent ;
1112import net .elytrarace .server .ecs .component .BracketConfigComponent ;
13+ import net .elytrarace .server .ecs .component .CupProgressComponent ;
1214import net .elytrarace .server .ecs .component .ElapsedTimeComponent ;
1315import net .elytrarace .server .ecs .component .ElytraFlightComponent ;
1416import net .elytrarace .server .ecs .component .GameModeComponent ;
17+ import net .elytrarace .server .ecs .component .MapRecordComponent ;
1518import net .elytrarace .server .ecs .component .PlayerRefComponent ;
1619import net .elytrarace .server .ecs .component .RingTrackerComponent ;
1720import net .elytrarace .server .ecs .component .ScoreComponent ;
2124
2225import java .time .Duration ;
2326import 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 */
3750public 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