Overview
AstralPack serves the network's resource packs. It is a Velocity proxy plugin only — there is no Paper module. At proxy startup it pre-computes each configured pack's SHA-1 by downloading it from its CDN URL, then applies and removes packs automatically while a player sits in the Minecraft configuration phase, keyed on the AstralCore server group the player is connecting to.
The plugin class is com.astralrealms.pack.AstralPack extends VelocityAstralPlugin. Its bootstrap is annotated @Plugin(id = "astralpack", name = "AstralPack", version = "1.0-SNAPSHOT", dependencies = {@Dependency(id = "astralcore")}) — AstralCore is a hard dependency and there is no plugin.yml/paper-plugin.yml.
onEnable registers, in order: the configuration load, the @packs tab-completion (PackBlueprintCompletionHandler), the PackBlueprint command context resolver (PackBlueprintContextResolver), the AstralPack command dependency, the /pack command, and the PackListener. Nothing else is registered — AstralPack contributes no actions, requirements, functions, placeholders, type adapters, custom events, or services. onDisable is empty.
Computing packs
AstralPack#loadConfiguration loads config.yml, clears the Set<ComputedPack> computedPacks, and then for every configured pack blueprint:
Derives the pack id
UUID.nameUUIDFromBytes(blueprint.url().getBytes()).Generates a fresh cache key.
Builds
ResourcePackInfo.resourcePackInfo().id(...).uri(URI.create(url + "?cacheKey=" + cacheKey)) .computeHashAndBuild().On completion, adds a
ComputedPack(blueprint, info, hash, cacheKey)tocomputedPacks.
computeHashAndBuild() downloads the ZIP from the proxy in order to hash it, so every pack URL must be reachable from the proxy host, not just from clients. Step 4 runs asynchronously in the future's whenComplete; if it fails, the proxy logs Failed to compute resource pack hash and that pack is simply never added — it is never sent, and findComputed returns null for it.
Cache busting
generateCacheKey() returns Long.toHexString(Double.doubleToLongBits(Math.random())). It is appended to the pack URL as a ?cacheKey=<hex> query string and regenerated on every configuration load and every /pack update. Clients cache resource packs by URL, so changing the query string forces clients (and intermediate caches) to re-download even when the file name on the CDN is unchanged. The key is stored on the ComputedPack record.
The ?cacheKey= is part of the URI used both to hash the pack proxy-side and to hand to the client, so both sides see the same bytes.
Sending
sendPack(Player, PackBlueprint) resolves the ComputedPack whose blueprint().id() matches; if there is none it logs the warning No computed pack found for blueprint id: {} and sends nothing.
sendPack(Player, ComputedPack, boolean force) first calls player.removeResourcePacks(info.info().id()) when force is set, then sends a ResourcePackRequest with .required(true), the single pack, and .prompt(info.blueprint().prompt()). All packs are sent as required.
The ComputedPack's byte[] hash is the hex-decoded form of the computed ResourcePackInfo#hash(); that byte array is what the listener compares against the client's applied and pending packs.
Configuration-phase flow
PackListener subscribes to Velocity's PlayerConfigurationEvent. That event is used rather than PlayerEnterConfigurationEvent because the latter is not fired for the initial configuration after login; PlayerConfigurationEvent fires on the initial join and on server switches for modern clients.
Resolve the group —
AstralVelocityAPI.servers().findById(<velocity server name>).map(MinecraftServer::group).orElse(null). If it isnull(the server is not registered in AstralCore'sServerService, or the lookup by Velocity server name misses), the listener returns immediately and AstralPack does nothing for that connection.Update packs — for every computed pack,
matches = blueprint.groups().contains(group).Matches and not already loaded → queued to send.
Does not match,
unload-on-group-changeistrue, and the pack is loaded →player.removeResourcePacks(pack.info().id())immediately. Removals are fire-and-forget and are never awaited.
"Loaded" means the pack's hash matches one in
player.getAppliedResourcePacks()orplayer.getPendingResourcePacks()(Arrays.equalson the raw hash), so a pack shared across groups is never re-prompted and a still-downloading pack is never re-sent.Nothing to send → the listener returns and configuration is not held.
Otherwise, before sending, the pending pack ids and a fresh
CompletableFutureare registered (awaiting/waiters, bothConcurrentHashMap) so a fast client response cannot race past the waiter. Each queued pack is then sent through the non-forcedsendPackpath.The subscriber returns
EventTask.resumeWhenComplete(...)on that waiter with a 20-second timeout; the configuration phase stays suspended until the client answers or the timeout elapses, and either way the player'sawaiting/waitersentries are removed.
Without this hold, Velocity ends configuration immediately, the client switches to the level-loading screen and tears down the confirm dialog before the player sees it — the pack then silently never loads.
PROMPT_TIMEOUT_SECONDS = 20L is a private static final constant and is not configurable. It is kept below the proxy's 30-second read timeout so a player who walks away does not take the backend connection down with them.
Releasing the wait
PlayerResourcePackStatusEvent is the primary release. It looks up the player's pending set and returns if there is none. Inside a synchronized block: a non-null getPackId() removes that id; a null getPackId() is treated as answering everything and clears the set. If ids remain pending the listener returns — in the multi-pack case every sent pack must be answered. Once the set is empty and something was actually removed, the waiter completes and configuration resumes.
The event fires for every client response, including intermediate ones. Resuming on the first response rather than on SUCCESSFUL is deliberate: by then the prompt has been answered and the client finishes downloading behind its own progress screen, whereas waiting for the download would hold the backend connection idle for its whole duration. A DECLINED response likewise counts as answered and resumes configuration.
DisconnectEvent unconditionally completes the waiter for the disconnecting player, so someone who leaves mid-prompt does not leave the wait hanging until the 20-second timeout.
Feature map
Area | Page |
|---|---|
| |
The | |
Installing the proxy plugin and its prerequisites |
Requirements
Dependency | Notes |
|---|---|
AstralCore ( | Hard |
Velocity | Needs |
An HTTP(S) host for the pack ZIPs | Must be reachable from the proxy (hashes are computed proxy-side) as well as from clients. |