Skip to content

Commit bb94461

Browse files
authored
feat(game): introduce GameMode enum and thread through session/orchestrator (#176)
* feat(game): introduce GameMode enum and thread through session/orchestrator (#167) Adds GameMode {RACE, PRACTICE} as a value type in shared/common and threads it through CupDefinition, GameSession, GameOrchestrator, and GamePhaseFactory as pure structural scaffolding — no behavioral changes. - GameMode enum: dslKey, minimumPlayers, leaderboardRanked, byName() lookup - MedalTier enum: DIAMOND/GOLD/SILVER/BRONZE/FINISH/DNF - MedalBrackets record: classify(elapsed, reference) → MedalTier - GameModeComponent ECS record for mode-aware systems - CupDefinition gains mode field (default RACE for existing JSON) - GameOrchestrator attaches GameModeComponent to game entity on start - GamePhaseFactory accepts GameMode overloads (no behavioral branching yet) - GameResultEntity gains gameMode + medalTier columns with composite index - shared/database now has implementation dependency on shared/common Closes #167 * fix(data): add type field to all portals in bundled sample maps (#103) All portals in the three bundled test maps were missing the required type field, causing CupLoader to fall back to STANDARD for every ring. Added "STANDARD" as the alpha default to all portals. Closes #103 * docs(adr): ADR-0011 — use Flyway for versioned DB migrations alongside Hibernate
1 parent b78ccfc commit bb94461

24 files changed

Lines changed: 1438 additions & 217 deletions

File tree

.claude/agent-memory/voyager-tech-writer/MEMORY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
- [ecs-system-registration](ecs_system_registration.md) — canonical steps and ordering rules for registering a new ECS system in GameOrchestrator
33
- [elytra-authority-model](elytra_authority_model.md) — client-authority model for elytra flight; which systems actually push velocity to the client, and the blocks/tick vs blocks/second unit boundary
44
- [gamemode-and-scoring-decisions](gamemode-and-scoring-decisions.md) — ADRs 0003/0004/0005: Race bracket scoring, Practice Mode redesign, GameMode enum
5+
- [adr-0011-flyway](adr-0011-flyway.md) — ADR-0011: Flyway as sole DDL authority, Hibernate set to validate, baseline V1__initial_schema.sql
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
name: adr-0011-flyway
3+
description: ADR-0011 chose Flyway as sole DDL authority with Hibernate set to validate; baseline script is V1__initial_schema.sql
4+
type: project
5+
---
6+
7+
ADR-0011 was accepted on 2026-04-25.
8+
9+
**Decision:** Flyway is the sole DDL authority for the `shared/database` module. Hibernate runs with `hbm2ddl.auto=validate` in production and staging — it never alters the schema, only verifies entity-schema alignment on startup.
10+
11+
**Layout:**
12+
13+
- Migration scripts live in `shared/database/src/main/resources/db/migration/`
14+
- Naming convention: `V{n}__{description}.sql`
15+
- `V1__initial_schema.sql` is the baseline that backfills the current production schema
16+
- Every entity change requires a matching new `V{n}__*.sql` script — entities are not the source of DDL changes alone
17+
18+
**CI gate:** Flyway `migrate` runs against an empty DB, then the app boots with Hibernate `validate`. PRs that change an `@Entity` without adding a migration fail the schema check.
19+
20+
**Trigger:** PR #176 added `game_mode` and `medal_tier` columns to `GameResultEntity` and surfaced the risk of `hbm2ddl.auto=update` in production.
21+
22+
**Why:** Future docs about database changes, deployment runbooks, or onboarding should cite ADR-0011 instead of re-explaining the Flyway/Hibernate split.
23+
24+
**How to apply:** When writing how-to guides for adding entities, reference docs for `shared/database`, or migration guides that cross schema boundaries, link ADR-0011 and remind contributors to add a `V{n}__*.sql` script. If the policy itself changes (rollback strategy, baseline reset, switch to Liquibase), supersede ADR-0011 — never edit it.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# ADR-0011: Use Flyway for versioned database schema migrations
2+
3+
## Status
4+
5+
Accepted
6+
7+
## Date
8+
9+
2026-04-25
10+
11+
## Decision makers
12+
13+
- Vault (database expert)
14+
- Atlas (architect)
15+
- Hangar (devops)
16+
17+
## Context and problem statement
18+
19+
The persistence layer in `shared/database` currently relies on Hibernate's `hbm2ddl.auto=update` to evolve the schema at application start. Voyager is approaching its first closed alpha, so the database now holds player data that must survive deployments. Vault flagged the risk while adding `game_mode` and `medal_tier` columns to `GameResultEntity` in PR #176: `hbm2ddl.auto=update` can silently skip complex changes, offers no rollback path, and leaves no audit trail of what DDL ran in which environment.
20+
21+
## Decision drivers
22+
23+
- Production deployments must not run unverified DDL against player data
24+
- Schema changes must be auditable and reproducible across environments
25+
- CI must be able to verify the schema before a release reaches production
26+
- The team already writes SQL by hand and prefers SQL-first tooling
27+
- Hibernate entity changes must remain the trigger for schema work, but must not be the executor
28+
29+
## Considered options
30+
31+
- Flyway as the sole DDL authority, Hibernate set to `validate`
32+
- Liquibase as the sole DDL authority, Hibernate set to `validate`
33+
- Keep `hbm2ddl.auto=update` and accept the risk
34+
- Hand-written SQL scripts applied manually, no migration tooling
35+
36+
## Decision outcome
37+
38+
Chosen option: **Flyway as the sole DDL authority, Hibernate set to `validate`**, because it gives versioned, auditable, rollback-capable migrations without forcing the team off SQL. Hibernate keeps its role as the entity mapper and now verifies on startup that the live schema matches the entity model, but it never alters the schema itself.
39+
40+
Concretely:
41+
42+
- Hibernate runs with `hibernate.hbm2ddl.auto=validate` in production and staging
43+
- Flyway scripts live in `shared/database/src/main/resources/db/migration/`
44+
- Scripts follow the naming convention `V{n}__{description}.sql` (for example, `V2__add_game_mode_to_game_result.sql`)
45+
- `V1__initial_schema.sql` backfills the complete current schema as a baseline
46+
- Every future schema change ships as a new numbered Flyway script — entities are never the source of DDL changes alone
47+
48+
### Consequences
49+
50+
- Good, because every schema change is versioned, reviewable in pull requests, and replayable in any environment
51+
- Good, because CI can run Flyway against an empty database and then start Hibernate in `validate` mode to catch entity-schema drift before deployment
52+
- Good, because production deployments no longer carry the risk of unintended DDL from `hbm2ddl.auto=update`
53+
- Bad, because every schema change now requires writing a migration script in addition to updating the entity — more discipline is required from contributors
54+
- Bad, because `V1__initial_schema.sql` must be authored carefully to match the current production schema exactly; a mismatch breaks the `validate` step on the first deploy
55+
- Neutral, because development databases can still be dropped and recreated by Flyway from `V1` upward, so the local workflow stays simple
56+
57+
### Confirmation
58+
59+
- `shared/database/src/main/resources/db/migration/V1__initial_schema.sql` exists and reproduces the current production schema
60+
- The Hibernate configuration sets `hibernate.hbm2ddl.auto=validate` for production and staging profiles
61+
- The CI pipeline runs Flyway `migrate` against a fresh database, then boots the application and verifies that Hibernate `validate` succeeds
62+
- Pull requests that change a `@Entity` class without adding a corresponding `V{n}__*.sql` script fail the CI schema check
63+
64+
## Pros and cons of the options
65+
66+
### Flyway as the sole DDL authority, Hibernate set to `validate`
67+
68+
- Good, because SQL-first scripts match the team's existing skills and review habits
69+
- Good, because Flyway integrates cleanly with Hibernate and is well-documented for this exact pairing
70+
- Good, because the migration history table gives an explicit audit trail in the database itself
71+
- Bad, because rolling back a migration requires writing an explicit down-script — Flyway Community does not auto-generate one
72+
73+
### Liquibase as the sole DDL authority, Hibernate set to `validate`
74+
75+
- Good, because Liquibase supports database-agnostic changelogs in XML, YAML, or JSON
76+
- Good, because Liquibase ships built-in rollback semantics for many change types
77+
- Bad, because the team writes raw SQL today; the changelog DSL adds a learning curve with no payoff for a single-database project
78+
- Bad, because tooling is heavier and the integration with Hibernate is less idiomatic than Flyway's
79+
80+
### Keep `hbm2ddl.auto=update`
81+
82+
- Good, because no new tooling is required
83+
- Bad, because Hibernate silently skips changes it cannot infer (column type narrowing, renames, index changes)
84+
- Bad, because there is no audit trail and no rollback path
85+
- Bad, because production DDL runs as a side effect of application start, with no chance for review
86+
87+
### Hand-written SQL scripts applied manually, no migration tooling
88+
89+
- Good, because it gives the team full control over every statement
90+
- Bad, because there is no version tracking, no CI integration, and no guarantee that environments stay in sync
91+
- Bad, because applying scripts manually in production is error-prone and does not scale past one operator
92+
93+
## More information
94+
95+
- PR #176 — added `game_mode` and `medal_tier` columns to `GameResultEntity` and surfaced the migration risk
96+
- [Flyway documentation](https://documentation.red-gate.com/fd)
97+
- [Hibernate `hbm2ddl.auto` reference](https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#schema-generation)
98+
- [ADR-0005: GameMode enum as session discriminator](0005-gamemode-enum-session-discriminator.md) — drove the entity changes that exposed the migration gap
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
package net.elytrarace.server.cup;
22

3+
import net.elytrarace.common.game.mode.GameMode;
4+
35
import java.util.List;
6+
import java.util.Objects;
47

58
/**
69
* Defines a cup consisting of an ordered sequence of maps that players race through.
710
*
811
* @param name the display name of the cup
12+
* @param mode the gameplay mode this cup runs under (RACE, PRACTICE, ...)
913
* @param maps the ordered list of maps in this cup
1014
*/
11-
public record CupDefinition(String name, List<MapDefinition> maps) {
15+
public record CupDefinition(String name, GameMode mode, List<MapDefinition> maps) {
1216
public CupDefinition {
17+
Objects.requireNonNull(mode, "mode must not be null");
1318
maps = List.copyOf(maps);
1419
}
1520
}

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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import net.elytrarace.common.cup.CupService;
44
import net.elytrarace.common.cup.model.FileCupDTO;
55
import net.elytrarace.common.cup.model.ResolvedCupDTO;
6+
import net.elytrarace.common.game.mode.GameMode;
67
import net.elytrarace.common.guide.GuidePointStore;
78
import net.elytrarace.common.map.MapService;
89
import net.elytrarace.common.map.model.FileMapDTO;
@@ -13,6 +14,7 @@
1314
import net.minestom.server.coordinate.Pos;
1415
import net.minestom.server.coordinate.Vec;
1516
import org.jetbrains.annotations.NotNull;
17+
import org.jetbrains.annotations.Nullable;
1618
import org.slf4j.Logger;
1719
import org.slf4j.LoggerFactory;
1820

@@ -76,8 +78,9 @@ public Optional<CupDefinition> loadCup(@NotNull FileCupDTO cupDTO) {
7678
return Optional.empty();
7779
}
7880

79-
LOGGER.info("Loaded cup '{}' with {} maps", cupDTO.name().asString(), maps.size());
80-
return Optional.of(new CupDefinition(cupDTO.name().asString(), maps));
81+
GameMode mode = resolveMode(cupDTO);
82+
LOGGER.info("Loaded cup '{}' (mode={}) with {} maps", cupDTO.name().asString(), mode.dslKey(), maps.size());
83+
return Optional.of(new CupDefinition(cupDTO.name().asString(), mode, maps));
8184

8285
} catch (InterruptedException e) {
8386
Thread.currentThread().interrupt();
@@ -89,6 +92,33 @@ public Optional<CupDefinition> loadCup(@NotNull FileCupDTO cupDTO) {
8992
}
9093
}
9194

95+
/**
96+
* Resolves the {@link GameMode} for a cup. The current {@link FileCupDTO} schema
97+
* does not yet carry a mode field, so this returns {@link GameMode#RACE} as the
98+
* default. When the DTO is extended in a future epic, the raw value should be
99+
* passed through {@link #parseMode(String, String)} so unknown values fall back
100+
* to RACE consistently.
101+
*/
102+
private GameMode resolveMode(@NotNull FileCupDTO cupDTO) {
103+
return parseMode(null, cupDTO.name().asString());
104+
}
105+
106+
/**
107+
* Parses an optional raw mode string, falling back to {@link GameMode#RACE}
108+
* when the value is absent, blank, or unrecognised.
109+
*/
110+
private GameMode parseMode(@Nullable String rawMode, @NotNull String cupName) {
111+
if (rawMode == null || rawMode.isBlank()) {
112+
return GameMode.RACE;
113+
}
114+
GameMode resolved = GameMode.byName(rawMode);
115+
if (resolved == null) {
116+
LOGGER.warn("Cup '{}' has unknown mode '{}' — falling back to RACE", cupName, rawMode);
117+
return GameMode.RACE;
118+
}
119+
return resolved;
120+
}
121+
92122
/**
93123
* Converts a {@link FileMapDTO} to a {@link MapDefinition}.
94124
* Returns empty if the world directory does not exist.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package net.elytrarace.server.ecs.component;
2+
3+
import net.elytrarace.common.ecs.Component;
4+
import net.elytrarace.common.game.mode.GameMode;
5+
6+
public record GameModeComponent(GameMode mode) implements Component {
7+
}

server/src/main/java/net/elytrarace/server/game/GameOrchestrator.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import net.elytrarace.server.ecs.component.CupProgressComponent;
1313
import net.elytrarace.server.ecs.component.ElytraFlightComponent;
1414
import net.elytrarace.server.ecs.component.FireworkBoostComponent;
15+
import net.elytrarace.server.ecs.component.GameModeComponent;
1516
import net.elytrarace.server.ecs.component.HudComponent;
1617
import net.elytrarace.server.ecs.component.PlayerRefComponent;
1718
import net.elytrarace.server.ecs.component.RingTrackerComponent;
@@ -112,6 +113,7 @@ public void startGame(CupDefinition cup) {
112113

113114
// Create game entity with cup progress and active map tracking
114115
gameEntity = GameEntityFactory.createGameEntity(cup);
116+
gameEntity.addComponent(new GameModeComponent(cup.mode()));
115117
entityManager.addEntity(gameEntity);
116118

117119
// Register ECS systems — order matters:
@@ -136,6 +138,7 @@ public void startGame(CupDefinition cup) {
136138
// - onMapSwitch: loadNextMap() is triggered when lobby ends
137139
// - onGamePhaseFinished: advance to next map or let series proceed to end phase
138140
phaseSeries = GamePhaseFactory.createGamePhases(entityManager,
141+
cup.mode(),
139142
() -> loadNextMap().exceptionally(ex -> {
140143
LOGGER.error("Failed to load first map after lobby", ex);
141144
return null;
@@ -222,6 +225,7 @@ private void restartGame(Phase currentPhase) {
222225

223226
// 4. Recreate phase series with fresh phases
224227
phaseSeries = GamePhaseFactory.createGamePhases(entityManager,
228+
gameEntity.getComponent(GameModeComponent.class).mode(),
225229
() -> loadNextMap().exceptionally(ex -> {
226230
LOGGER.error("Failed to load map after lobby", ex);
227231
return null;

server/src/main/java/net/elytrarace/server/game/GameSession.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package net.elytrarace.server.game;
22

3+
import net.elytrarace.common.game.mode.GameMode;
34
import net.elytrarace.server.cup.CupDefinition;
45
import net.elytrarace.server.cup.CupFlowService;
56
import net.elytrarace.server.scoring.ScoringService;
@@ -21,6 +22,7 @@ public final class GameSession {
2122

2223
private final UUID sessionId;
2324
private final CupDefinition cup;
25+
private final GameMode mode;
2426
private final CupFlowService cupFlow;
2527
private final ScoringService scoring;
2628
private final Set<UUID> players = ConcurrentHashMap.newKeySet();
@@ -30,10 +32,15 @@ public final class GameSession {
3032
public GameSession(UUID sessionId, CupDefinition cup, CupFlowService cupFlow, ScoringService scoring) {
3133
this.sessionId = sessionId;
3234
this.cup = cup;
35+
this.mode = cup.mode();
3336
this.cupFlow = cupFlow;
3437
this.scoring = scoring;
3538
}
3639

40+
public GameMode getMode() {
41+
return mode;
42+
}
43+
3744
public UUID getSessionId() {
3845
return sessionId;
3946
}

server/src/main/java/net/elytrarace/server/phase/GamePhaseFactory.java

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
package net.elytrarace.server.phase;
22

33
import net.elytrarace.common.ecs.EntityManager;
4+
import net.elytrarace.common.game.mode.GameMode;
45
import net.elytrarace.server.persistence.GameResultPersistenceService;
56
import net.theevilreaper.xerus.api.phase.LinearPhaseSeries;
67
import net.theevilreaper.xerus.api.phase.Phase;
78
import org.jetbrains.annotations.Nullable;
89

910
import java.util.List;
11+
import java.util.Objects;
1012

1113
/**
1214
* Factory that assembles the standard game phase series for the Minestom server.
1315
* <p>
1416
* The series follows the order: Lobby -> Game -> End.
1517
* The {@link LinearPhaseSeries} automatically advances through each phase
1618
* when the previous one finishes.
19+
* <p>
20+
* The {@link GameMode} parameter is currently accepted for forward compatibility
21+
* (mode-specific phase composition will be introduced in a later epic). For now,
22+
* both {@code RACE} and {@code PRACTICE} produce the same phase series.
1723
*/
1824
public final class GamePhaseFactory {
1925

@@ -22,30 +28,51 @@ private GamePhaseFactory() {
2228
}
2329

2430
/**
25-
* Creates a linear phase series containing lobby, game, and end phases
26-
* with default timing configurations.
27-
*
28-
* @param entityManager the ECS entity manager driving the game loop
29-
* @return a ready-to-start phase series
31+
* Creates a linear phase series with default timing configurations and the default
32+
* {@link GameMode#RACE} mode.
3033
*/
3134
public static LinearPhaseSeries<Phase> createGamePhases(EntityManager entityManager) {
32-
return createGamePhases(entityManager, null, null, null);
35+
return createGamePhases(entityManager, GameMode.RACE, null, null, null);
36+
}
37+
38+
/**
39+
* Creates a linear phase series without persistence hooks. Defaults to
40+
* {@link GameMode#RACE}.
41+
*/
42+
public static LinearPhaseSeries<Phase> createGamePhases(EntityManager entityManager,
43+
@Nullable Runnable onMapSwitch,
44+
@Nullable Runnable onGamePhaseFinished) {
45+
return createGamePhases(entityManager, GameMode.RACE, onMapSwitch, onGamePhaseFinished, null);
46+
}
47+
48+
/**
49+
* Creates a linear phase series with the given persistence service. Defaults to
50+
* {@link GameMode#RACE}.
51+
*/
52+
public static LinearPhaseSeries<Phase> createGamePhases(EntityManager entityManager,
53+
@Nullable Runnable onMapSwitch,
54+
@Nullable Runnable onGamePhaseFinished,
55+
@Nullable GameResultPersistenceService gameResultPersistence) {
56+
return createGamePhases(entityManager, GameMode.RACE, onMapSwitch, onGamePhaseFinished, gameResultPersistence);
3357
}
3458

3559
/**
36-
* Creates a linear phase series without persistence hooks. Kept for tests and
37-
* callers that do not run against a live database.
60+
* Creates a linear phase series for the given {@link GameMode} without persistence hooks.
3861
*/
3962
public static LinearPhaseSeries<Phase> createGamePhases(EntityManager entityManager,
63+
GameMode mode,
4064
@Nullable Runnable onMapSwitch,
4165
@Nullable Runnable onGamePhaseFinished) {
42-
return createGamePhases(entityManager, onMapSwitch, onGamePhaseFinished, null);
66+
return createGamePhases(entityManager, mode, onMapSwitch, onGamePhaseFinished, null);
4367
}
4468

4569
/**
46-
* Creates a linear phase series containing lobby, game, and end phases.
70+
* Creates a linear phase series containing lobby, game, and end phases for the
71+
* given {@link GameMode}.
4772
*
4873
* @param entityManager the ECS entity manager driving the game loop
74+
* @param mode the game mode this series runs under; currently informational
75+
* (mode-specific composition arrives in a later epic)
4976
* @param onMapSwitch callback invoked when the lobby phase ends, before the game phase starts;
5077
* use this to trigger map loading and player teleportation
5178
* @param onGamePhaseFinished callback invoked when the game phase finishes (race duration expired
@@ -55,9 +82,12 @@ public static LinearPhaseSeries<Phase> createGamePhases(EntityManager entityMana
5582
* @return a ready-to-start phase series
5683
*/
5784
public static LinearPhaseSeries<Phase> createGamePhases(EntityManager entityManager,
85+
GameMode mode,
5886
@Nullable Runnable onMapSwitch,
5987
@Nullable Runnable onGamePhaseFinished,
6088
@Nullable GameResultPersistenceService gameResultPersistence) {
89+
Objects.requireNonNull(entityManager, "entityManager must not be null");
90+
Objects.requireNonNull(mode, "mode must not be null");
6191
var lobby = new MinestomLobbyPhase(120, onMapSwitch);
6292
var game = new MinestomGamePhase(entityManager,
6393
MinestomGamePhase.DEFAULT_RACE_DURATION_TICKS, onGamePhaseFinished);

0 commit comments

Comments
 (0)