Skip to content

Commit 5cbd9c0

Browse files
committed
feat(server): auto-load cups and maps from run/data/ on startup
1 parent f0dcb09 commit 5cbd9c0

4 files changed

Lines changed: 243 additions & 30 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77
# Node / semantic-release
88
node_modules/
9+
10+
# Local server run directory — worlds and data are not committed
11+
run/data/
12+
run/worlds/
913
*.war
1014
*.nar
1115
*.ear

server/build.gradle.kts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,17 @@ tasks {
5252
"-Xms256M",
5353
"-Xmx512M"
5454
)
55-
// Pass through host/port via Gradle properties:
55+
// Working directory: run/ at the project root
56+
// Place your data and worlds here:
57+
// run/data/cups/cups.json
58+
// run/data/maps/{worldName}/map.json
59+
// run/worlds/{worldName}/
60+
workingDir = rootProject.file("run")
61+
// Override data/worlds paths via Gradle properties if needed:
62+
// ./gradlew :server:runServer -PdataPath=/path/to/data -PworldsPath=/path/to/worlds
63+
providers.gradleProperty("dataPath").orNull?.let { systemProperty("VOYAGER_DATA_PATH", it) }
64+
providers.gradleProperty("worldsPath").orNull?.let { systemProperty("VOYAGER_WORLDS_PATH", it) }
65+
// Pass through host/port:
5666
// ./gradlew :server:runServer -Phost=0.0.0.0 -Pport=25565
5767
val host = providers.gradleProperty("host").orElse("0.0.0.0")
5868
val port = providers.gradleProperty("port").orElse("25565")

server/src/main/java/net/elytrarace/server/VoyagerServer.java

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,44 @@
11
package net.elytrarace.server;
22

3+
import net.elytrarace.common.cup.CupService;
4+
import net.elytrarace.common.map.MapService;
35
import net.elytrarace.server.cup.CupDefinition;
4-
import net.elytrarace.server.cup.MapDefinition;
6+
import net.elytrarace.server.cup.CupLoader;
57
import net.elytrarace.server.game.GameOrchestrator;
6-
import net.elytrarace.server.physics.Ring;
78
import net.elytrarace.server.player.PlayerEventHandler;
89
import net.elytrarace.server.player.PlayerService;
910
import net.elytrarace.server.player.PlayerServiceImpl;
1011
import net.elytrarace.server.world.AnvilMapInstanceService;
1112
import net.elytrarace.server.world.MapInstanceService;
1213
import net.minestom.server.MinecraftServer;
13-
import net.minestom.server.coordinate.Pos;
14-
import net.minestom.server.coordinate.Vec;
1514
import net.minestom.server.instance.InstanceContainer;
1615
import net.minestom.server.instance.InstanceManager;
1716
import net.minestom.server.instance.block.Block;
1817
import org.slf4j.Logger;
1918
import org.slf4j.LoggerFactory;
2019

2120
import java.nio.file.Path;
22-
import java.util.List;
2321

2422
/**
2523
* Voyager standalone Minestom server entry point.
26-
* Manages the server lifecycle: initialization, instance creation, and shutdown.
24+
* Manages the server lifecycle: initialization, data loading, and startup.
25+
*
26+
* <p>Directory layout (relative to working directory):
27+
* <pre>
28+
* run/
29+
* data/
30+
* cups/cups.json
31+
* maps/{worldName}/map.json
32+
* maps/{worldName}/portals.json
33+
* worlds/
34+
* {worldName}/ ← Anvil world directories from the Setup Server
35+
* </pre>
36+
*
37+
* Override the defaults via system properties:
38+
* <ul>
39+
* <li>{@code -DVOYAGER_DATA_PATH=...} — path to the data directory (default: {@code run/data})</li>
40+
* <li>{@code -DVOYAGER_WORLDS_PATH=...} — path to the worlds directory (default: {@code run/worlds})</li>
41+
* </ul>
2742
*/
2843
public final class VoyagerServer {
2944

@@ -37,8 +52,16 @@ public final class VoyagerServer {
3752
private final PlayerEventHandler playerEventHandler;
3853
private final MapInstanceService mapInstanceService;
3954
private final GameOrchestrator gameOrchestrator;
55+
private final CupLoader cupLoader;
4056

4157
public VoyagerServer() {
58+
this(
59+
Path.of(System.getProperty("VOYAGER_DATA_PATH", "run/data")),
60+
Path.of(System.getProperty("VOYAGER_WORLDS_PATH", "run/worlds"))
61+
);
62+
}
63+
64+
public VoyagerServer(Path dataPath, Path worldsPath) {
4265
this.server = MinecraftServer.init();
4366

4467
InstanceManager instanceManager = MinecraftServer.getInstanceManager();
@@ -51,6 +74,13 @@ public VoyagerServer() {
5174

5275
this.mapInstanceService = new AnvilMapInstanceService(instanceManager);
5376
this.gameOrchestrator = new GameOrchestrator(playerService, mapInstanceService);
77+
78+
var cupService = CupService.create(dataPath);
79+
var mapService = MapService.create(dataPath);
80+
this.cupLoader = new CupLoader(cupService, mapService, worldsPath);
81+
82+
LOGGER.info("Data path: {}", dataPath.toAbsolutePath());
83+
LOGGER.info("Worlds path: {}", worldsPath.toAbsolutePath());
5484
}
5585

5686
public void start() {
@@ -61,6 +91,18 @@ public void start(String host, int port) {
6191
LOGGER.info("Starting Voyager server on {}:{}", host, port);
6292
server.start(host, port);
6393
LOGGER.info("Voyager server started successfully");
94+
95+
cupLoader.loadFirstCup().ifPresentOrElse(
96+
cup -> {
97+
LOGGER.info("Auto-starting cup '{}'", cup.name());
98+
gameOrchestrator.startGame(cup);
99+
},
100+
() -> LOGGER.warn("No cup loaded — place cups/maps under run/data/ and worlds under run/worlds/")
101+
);
102+
}
103+
104+
public void startGame(CupDefinition cup) {
105+
gameOrchestrator.startGame(cup);
64106
}
65107

66108
public InstanceContainer getLobbyInstance() {
@@ -79,28 +121,8 @@ public GameOrchestrator getGameOrchestrator() {
79121
return gameOrchestrator;
80122
}
81123

82-
/**
83-
* Starts a game session for the given cup definition.
84-
* Delegates to the {@link GameOrchestrator} to wire up all subsystems.
85-
*
86-
* @param cup the cup definition to start
87-
*/
88-
public void startGame(CupDefinition cup) {
89-
gameOrchestrator.startGame(cup);
90-
}
91-
92-
/**
93-
* Creates a demo cup with three placeholder maps for testing purposes.
94-
* The maps have no rings and use temporary world directories.
95-
*
96-
* @return a demo cup definition
97-
*/
98-
public static CupDefinition createDemoCup() {
99-
var ring = new Ring(new Vec(0, 50, 50), new Vec(0, 0, 1), 5.0, 10);
100-
var map1 = new MapDefinition("Demo Map 1", Path.of("/tmp/demo-map-1"), List.of(ring), new Pos(0, 60, 0));
101-
var map2 = new MapDefinition("Demo Map 2", Path.of("/tmp/demo-map-2"), List.of(ring), new Pos(0, 60, 0));
102-
var map3 = new MapDefinition("Demo Map 3", Path.of("/tmp/demo-map-3"), List.of(ring), new Pos(0, 60, 0));
103-
return new CupDefinition("Demo Cup", List.of(map1, map2, map3));
124+
public CupLoader getCupLoader() {
125+
return cupLoader;
104126
}
105127

106128
public static void main(String[] args) {
@@ -114,7 +136,7 @@ public static void main(String[] args) {
114136
}
115137
}
116138

117-
VoyagerServer voyagerServer = new VoyagerServer();
139+
var voyagerServer = new VoyagerServer();
118140

119141
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
120142
LOGGER.info("Shutting down Voyager server...");
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package net.elytrarace.server.cup;
2+
3+
import net.elytrarace.common.cup.CupService;
4+
import net.elytrarace.common.cup.model.FileCupDTO;
5+
import net.elytrarace.common.cup.model.ResolvedCupDTO;
6+
import net.elytrarace.common.map.MapService;
7+
import net.elytrarace.common.map.model.FileMapDTO;
8+
import net.elytrarace.common.map.model.LocationDTO;
9+
import net.elytrarace.common.map.model.PortalDTO;
10+
import net.elytrarace.server.physics.Ring;
11+
import net.minestom.server.coordinate.Pos;
12+
import net.minestom.server.coordinate.Vec;
13+
import org.jetbrains.annotations.NotNull;
14+
import org.slf4j.Logger;
15+
import org.slf4j.LoggerFactory;
16+
17+
import java.nio.file.Path;
18+
import java.util.ArrayList;
19+
import java.util.List;
20+
import java.util.Optional;
21+
import java.util.concurrent.ExecutionException;
22+
23+
/**
24+
* Converts shared DTOs (CupDTO, FileMapDTO, PortalDTO) into server-side domain objects
25+
* (CupDefinition, MapDefinition, Ring) that the game engine can use directly.
26+
*/
27+
public final class CupLoader {
28+
29+
private static final Logger LOGGER = LoggerFactory.getLogger(CupLoader.class);
30+
private static final int DEFAULT_RING_POINTS = 10;
31+
32+
private final CupService cupService;
33+
private final MapService mapService;
34+
private final Path worldsPath;
35+
36+
public CupLoader(@NotNull CupService cupService, @NotNull MapService mapService, @NotNull Path worldsPath) {
37+
this.cupService = cupService;
38+
this.mapService = mapService;
39+
this.worldsPath = worldsPath;
40+
}
41+
42+
/**
43+
* Loads the first available cup and converts it to a {@link CupDefinition}.
44+
* Returns empty if no cups are configured.
45+
*/
46+
public Optional<CupDefinition> loadFirstCup() {
47+
var cups = cupService.getCups();
48+
if (cups.isEmpty()) {
49+
LOGGER.warn("No cups configured — server will remain in lobby mode");
50+
return Optional.empty();
51+
}
52+
return loadCup(cups.getFirst());
53+
}
54+
55+
/**
56+
* Resolves a {@link FileCupDTO} into a {@link CupDefinition} by loading all referenced maps.
57+
*/
58+
public Optional<CupDefinition> loadCup(@NotNull FileCupDTO cupDTO) {
59+
try {
60+
var resolved = (ResolvedCupDTO) mapService.getMapByCup(cupDTO).get();
61+
var maps = new ArrayList<MapDefinition>();
62+
63+
for (var mapDTO : resolved.maps()) {
64+
if (!(mapDTO instanceof FileMapDTO fileMapDTO)) continue;
65+
var mapDef = convertMap(fileMapDTO);
66+
mapDef.ifPresent(maps::add);
67+
}
68+
69+
if (maps.isEmpty()) {
70+
LOGGER.warn("Cup '{}' resolved to zero loadable maps — skipping", cupDTO.name().asString());
71+
return Optional.empty();
72+
}
73+
74+
LOGGER.info("Loaded cup '{}' with {} maps", cupDTO.name().asString(), maps.size());
75+
return Optional.of(new CupDefinition(cupDTO.name().asString(), maps));
76+
77+
} catch (InterruptedException e) {
78+
Thread.currentThread().interrupt();
79+
LOGGER.error("Interrupted while loading cup '{}'", cupDTO.name().asString(), e);
80+
return Optional.empty();
81+
} catch (ExecutionException e) {
82+
LOGGER.error("Failed to resolve maps for cup '{}'", cupDTO.name().asString(), e.getCause());
83+
return Optional.empty();
84+
}
85+
}
86+
87+
/**
88+
* Converts a {@link FileMapDTO} to a {@link MapDefinition}.
89+
* Returns empty if the world directory does not exist.
90+
*/
91+
private Optional<MapDefinition> convertMap(@NotNull FileMapDTO dto) {
92+
var worldDir = worldsPath.resolve(dto.world());
93+
if (!worldDir.toFile().exists()) {
94+
LOGGER.warn("World directory not found for map '{}': {} — skipping map",
95+
dto.name().asString(), worldDir.toAbsolutePath());
96+
return Optional.empty();
97+
}
98+
99+
var portals = new ArrayList<>(dto.portals());
100+
var rings = portals.stream()
101+
.map(this::convertPortalToRing)
102+
.toList();
103+
104+
var spawnPos = deriveSpawn(portals);
105+
LOGGER.info(" Map '{}' — {} rings, spawn {}", dto.name().asString(), rings.size(), spawnPos);
106+
107+
return Optional.of(new MapDefinition(dto.name().asString(), worldDir, rings, spawnPos));
108+
}
109+
110+
/**
111+
* Converts a portal checkpoint into a ring.
112+
* The ring center is derived from the location marked as center=true.
113+
* The radius is the max distance from center to any edge location.
114+
* The normal is computed from edge vectors when possible; defaults to (0,0,1).
115+
*/
116+
private Ring convertPortalToRing(@NotNull PortalDTO portal) {
117+
var locations = portal.locations();
118+
119+
var centerLoc = locations.stream()
120+
.filter(LocationDTO::center)
121+
.findFirst()
122+
.orElse(locations.isEmpty() ? new LocationDTO(0, 64, 0, true) : locations.getFirst());
123+
124+
var center = new Vec(centerLoc.x(), centerLoc.y(), centerLoc.z());
125+
126+
var edgePoints = locations.stream()
127+
.filter(l -> !l.center())
128+
.map(l -> new Vec(l.x(), l.y(), l.z()))
129+
.toList();
130+
131+
double radius = edgePoints.stream()
132+
.mapToDouble(e -> center.distance(e))
133+
.max()
134+
.orElse(3.0);
135+
136+
var normal = computeNormal(center, edgePoints);
137+
138+
return new Ring(center, normal, radius, DEFAULT_RING_POINTS);
139+
}
140+
141+
/**
142+
* Computes the plane normal from edge points around a center.
143+
* Uses the cross product of the first two edge vectors when available.
144+
* Falls back to (0,0,1) if the normal cannot be determined.
145+
*/
146+
private Vec computeNormal(@NotNull Vec center, @NotNull List<Vec> edgePoints) {
147+
if (edgePoints.size() < 2) {
148+
return new Vec(0, 0, 1);
149+
}
150+
var v1 = edgePoints.get(0).sub(center).normalize();
151+
var v2 = edgePoints.get(1).sub(center).normalize();
152+
var cross = v1.cross(v2);
153+
var len = cross.length();
154+
if (len < 1e-6) {
155+
return new Vec(0, 0, 1);
156+
}
157+
return cross.div(len);
158+
}
159+
160+
/**
161+
* Derives a spawn position from the first portal's center location.
162+
* Adds 2 blocks of Y clearance so players don't spawn inside the ring.
163+
* Falls back to (0, 64, 0) if no portals are present.
164+
*/
165+
private Pos deriveSpawn(@NotNull List<? extends PortalDTO> portals) {
166+
if (portals.isEmpty()) {
167+
return new Pos(0, 64, 0);
168+
}
169+
var first = portals.getFirst();
170+
var centerLoc = first.locations().stream()
171+
.filter(LocationDTO::center)
172+
.findFirst()
173+
.orElse(first.locations().isEmpty() ? new LocationDTO(0, 64, 0, true) : first.locations().getFirst());
174+
175+
return new Pos(centerLoc.x(), centerLoc.y() + 2, centerLoc.z());
176+
}
177+
}

0 commit comments

Comments
 (0)