diff --git a/crates/chunk-build/README.md b/crates/chunk-build/README.md index c61a9925..45251fd8 100644 --- a/crates/chunk-build/README.md +++ b/crates/chunk-build/README.md @@ -70,16 +70,15 @@ composes down the directory tree. An app's own backend functions live in its `se `apps//app.toml` are still read and belong to the root scope; an app can't have both. An app's `worlds` and the `packs` of apps and scopes name assets by `source`: a path inside the app's `assets/`, or for -a scope the project's `assets/`, with no symlinks. A world is a `.polar` file or an Anvil save directory, optionally -cropped with `chunks: { from: [x, z], to: [x, z] }`; a pack is a directory with `pack.mcmeta` or a `.zip`, optionally -`required` and with a `prompt`. Pack names are unique in the project, and an app's players hold the packs of its scopes, -outermost first, then its own. Gameplay code names worlds through the generated `Worlds`, such as `Worlds.Arena.KOTH`. +a scope the project's `assets/`, with no symlinks. A world is a `.polar` file; Chunk doesn't convert Anvil saves. A pack +is a directory with `pack.mcmeta` or a `.zip`, optionally `required` and with a `prompt`. Pack names are unique in the +project, and an app's players hold the packs of its scopes, outermost first, then its own. Gameplay code names worlds +through the generated `Worlds`, such as `Worlds.Arena.KOTH`. Manifests are read without running any code: the default export must be a direct `defineApp({...})` or `defineScope({...})` call, and IDs, runtime settings, implementation keys and destination identities must be literals. `chunk inspect` prints what the tools read, as JSON with `version: 1`, `apps` and `local`; each app lists its `worlds` -with their project-relative `source`, `format` (`anvil` or `polar`) and `chunks`. `inspect`, `gen`, `build` and `dev` -need `chunk.toml`; `codegen` doesn't. +with their project-relative `source`. `inspect`, `gen`, `build` and `dev` need `chunk.toml`; `codegen` doesn't. ## Backend compilation @@ -128,9 +127,8 @@ A release holds: A release holds no assets. `chunk build` then builds the project's asset revision into `dist/assets/` (`.chunk/local/assets/` for `chunk dev`): the files of `assets/` and each app's `assets/`, each app's worlds and every -pack, stored once by SHA-256 under `blobs/`, and the revision's manifest under `revisions/.json`. Anvil worlds are -read as Gradle converted them to `.chunk/build/worlds//.polar`, and pack directories are zipped reproducibly. -A deployment pairs a release with a revision holding every world and pack the release declares. +pack, stored once by SHA-256 under `blobs/`, and the revision's manifest under `revisions/.json`. Pack directories +are zipped reproducibly. A deployment pairs a release with a revision holding every world and pack the release declares. The release ID is one SHA-256 over the sorted payload names and bytes plus the normalized manifest, so identical inputs give the same ID and archive bytes on any machine; archives use fixed ordering, permissions and timestamps. Local paths, diff --git a/crates/chunk-build/sdk/src/apps.ts b/crates/chunk-build/sdk/src/apps.ts index 8434b828..86f32f74 100644 --- a/crates/chunk-build/sdk/src/apps.ts +++ b/crates/chunk-build/sdk/src/apps.ts @@ -19,11 +19,10 @@ export interface PackOptions { /** Plain text the client shows when it asks the player to accept the pack. */ readonly prompt?: string; } -/** A world in the app's `assets/`: a `.polar` file, or an Anvil save directory the build converts to Polar. */ +/** A world in the app's `assets/`. */ export interface WorldOptions { + /** A `.polar` file, relative to the app's `assets/`. */ readonly source: string; - /** The inclusive chunk coordinates an Anvil save is cropped to. */ - readonly chunks?: { readonly from: readonly [number, number]; readonly to: readonly [number, number] }; } export interface ScopeOptions { readonly hooks?: Readonly>; diff --git a/crates/chunk-build/sdk/test/types.ts b/crates/chunk-build/sdk/test/types.ts index bd6bc126..306fb16c 100644 --- a/crates/chunk-build/sdk/test/types.ts +++ b/crates/chunk-build/sdk/test/types.ts @@ -438,7 +438,7 @@ defineApp({ defineApp({ id: "lobby", destinations: { main: { implementation: "default", key: "lobby" } } }); defineApp({ id: "koth", - worlds: { hill: { source: "worlds/hill", chunks: { from: [-8, -8], to: [7, 7] } } }, + worlds: { hill: { source: "worlds/hill.polar" } }, packs: { hud: { source: "packs/hud", required: true, prompt: "Needed for the HUD" } }, }); defineScope({ packs: { base: { source: "packs/base" } } }); diff --git a/crates/chunk-build/src/assets/build.rs b/crates/chunk-build/src/assets/build.rs index 291ba7bb..cb14ffc0 100644 --- a/crates/chunk-build/src/assets/build.rs +++ b/crates/chunk-build/src/assets/build.rs @@ -15,7 +15,7 @@ use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions}; use super::Store; use crate::{ - project::{self, Pack, WorldFormat}, + project::{self, Pack}, publication::MAX_COMPONENTS, }; @@ -23,11 +23,11 @@ use crate::{ const POLAR_MAGIC: &[u8; 4] = b"Polr"; /// Builds the asset revision of the project at `root` into `store` and writes it there: the files of `assets/` and of -/// each app's `assets/`, except declared world and pack sources; each app's worlds, Polar sources as they are and Anvil -/// saves as the Gradle build converted them; and every pack, directories zipped reproducibly. +/// each app's `assets/`, except declared world and pack sources; each app's Polar worlds; and every pack, directories +/// zipped reproducibly. /// # Errors -/// Rejects invalid declarations, symlinks, nonportable paths, unconverted Anvil worlds, sources of the wrong format, -/// revisions over the limits of [`chunk_contract`], and filesystem failures. +/// Rejects invalid declarations, symlinks, nonportable paths, sources of the wrong format, revisions over the limits of +/// [`chunk_contract`], and filesystem failures. pub fn build_revision(root: &Path, store: &Store) -> io::Result { let inventory = project::inspect_inventory(root)?; let mut builder = Builder { store, entries: 0 }; @@ -39,20 +39,7 @@ pub fn build_revision(root: &Path, store: &Store) -> io::Result { for app in &inventory.apps { let mut assets = chunk_contract::AppAssets::default(); for (name, world) in &app.worlds { - let path = match world.format { - WorldFormat::Polar => root.join(&world.source), - WorldFormat::Anvil => root.join(format!(".chunk/build/worlds/{}/{name}.polar", app.id)), - }; - if world.format == WorldFormat::Anvil && fs::symlink_metadata(&path).is_err() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!( - "Anvil world {name} of app {} is not converted yet ({} is missing); run `chunk build`", - app.id, - path.display() - ), - )); - } + let path = root.join(&world.source); let mut magic = [0; 4]; regular(&path)?.read_exact(&mut magic).ok(); if &magic != POLAR_MAGIC { diff --git a/crates/chunk-build/src/assets/tests.rs b/crates/chunk-build/src/assets/tests.rs index 69d7ace6..e4a5a545 100644 --- a/crates/chunk-build/src/assets/tests.rs +++ b/crates/chunk-build/src/assets/tests.rs @@ -10,18 +10,18 @@ fn write(root: &Path, path: &str, bytes: &[u8]) { fs::write(path, bytes).unwrap(); } -/// A project whose arena app has a Polar and an Anvil world and a pack, below a root scope with another pack. +/// A project whose arena app has two worlds and a pack, below a root scope with another pack. fn project(root: &Path) { write(root, "chunk.toml", b""); write(root, "apps/scope.ts", b"export default defineScope({packs:{base:{source:'packs/base.zip'}}});"); write( root, "apps/arena/app.ts", - b"export default defineApp({id:'arena',worlds:{lobby:{source:'worlds/lobby.polar'},koth:{source:'worlds/koth'}},packs:{ui:{source:'packs/ui',required:true}}});", + b"export default defineApp({id:'arena',worlds:{lobby:{source:'worlds/lobby.polar'},koth:{source:'worlds/koth.polar'}},packs:{ui:{source:'packs/ui',required:true}}});", ); write(root, "apps/arena/build.gradle.kts", b""); write(root, "apps/arena/assets/worlds/lobby.polar", POLAR); - write(root, "apps/arena/assets/worlds/koth/level.dat", b"anvil"); + write(root, "apps/arena/assets/worlds/koth.polar", b"Polr koth"); write(root, "apps/arena/assets/packs/ui/pack.mcmeta", b"{}"); write(root, "apps/arena/assets/packs/ui/assets/minecraft/lang/en_us.json", b"{}"); write(root, "apps/arena/assets/kits/default.json", b"kit"); @@ -37,12 +37,6 @@ fn revisions_hold_plain_files_worlds_and_reproducible_packs() { let root = project_directory.path(); project(root); let first = Store::new(root.join("dist/assets")); - let error = build_revision(root, &first).unwrap_err().to_string(); - assert!( - error.contains("Anvil world koth of app arena is not converted") && error.contains("chunk build"), - "{error}" - ); - write(root, ".chunk/build/worlds/arena/koth.polar", b"Polr converted"); let revision = build_revision(root, &first).unwrap(); assert_eq!(revision.shared.keys().collect::>(), ["config/game.json"]); @@ -63,7 +57,7 @@ fn revisions_hold_plain_files_worlds_and_reproducible_packs() { let second = build_revision(root, &Store::new(root.join("other"))).unwrap(); assert_eq!(second.id(), revision.id()); - write(root, ".chunk/build/worlds/arena/koth.polar", b"not polar"); + write(root, "apps/arena/assets/worlds/koth.polar", b"not polar"); let error = build_revision(root, &first).unwrap_err().to_string(); assert!(error.contains("not a Polar world"), "{error}"); } @@ -73,7 +67,6 @@ fn materialized_directories_link_read_only_blobs_and_are_reused() { let project_directory = tempfile::tempdir().unwrap(); let root = project_directory.path(); project(root); - write(root, ".chunk/build/worlds/arena/koth.polar", b"Polr converted"); let store = Store::new(root.join("store")); let revision = build_revision(root, &store).unwrap(); @@ -81,7 +74,7 @@ fn materialized_directories_link_read_only_blobs_and_are_reused() { assert_eq!(directory, store.root().join("apps").join(revision.id()).join("arena")); assert_eq!(fs::read(directory.join("revision.json")).unwrap(), revision.encode()); assert_eq!(fs::read(directory.join("worlds/lobby.polar")).unwrap(), POLAR); - assert_eq!(fs::read(directory.join("worlds/koth.polar")).unwrap(), b"Polr converted"); + assert_eq!(fs::read(directory.join("worlds/koth.polar")).unwrap(), b"Polr koth"); assert_eq!(fs::read(directory.join("app/kits/default.json")).unwrap(), b"kit"); assert_eq!(fs::read(directory.join("shared/config/game.json")).unwrap(), b"config"); assert!(!directory.join("packs").exists() && !directory.join("app/worlds").exists()); @@ -102,7 +95,6 @@ fn incomplete_materialized_directories_are_rebuilt() { let project_directory = tempfile::tempdir().unwrap(); let root = project_directory.path(); project(root); - write(root, ".chunk/build/worlds/arena/koth.polar", b"Polr converted"); let store = Store::new(root.join("store")); let revision = build_revision(root, &store).unwrap(); let directory = materialize(&store, &revision, "arena").unwrap(); diff --git a/crates/chunk-build/src/project.rs b/crates/chunk-build/src/project.rs index e4ec7ac2..cf65dcaa 100644 --- a/crates/chunk-build/src/project.rs +++ b/crates/chunk-build/src/project.rs @@ -14,7 +14,7 @@ use crate::{MachineProfile, valid_id}; pub(crate) mod assets; pub(crate) mod authoring; -pub use assets::{ChunkRange, Pack, World, WorldFormat}; +pub use assets::{Pack, World}; /// Apps, domain scopes and authored modules found in one pass over the project tree. #[derive(Default)] diff --git a/crates/chunk-build/src/project/assets.rs b/crates/chunk-build/src/project/assets.rs index 160faf4e..27803fe2 100644 --- a/crates/chunk-build/src/project/assets.rs +++ b/crates/chunk-build/src/project/assets.rs @@ -7,7 +7,7 @@ use std::{ }; use chunk_contract::{AssetContract, PackDeclaration}; -use oxc_ast::ast::{Expression, UnaryOperator}; +use oxc_ast::ast::Expression; use serde::Serialize; use super::{ @@ -19,26 +19,8 @@ use crate::valid_id; #[derive(Debug, Serialize)] pub struct World { - /// Project-relative path with forward slashes. + /// Project-relative path with forward slashes to a `.polar` file. pub source: String, - pub format: WorldFormat, - /// The inclusive chunk range an Anvil world is cropped to. - #[serde(skip_serializing_if = "Option::is_none")] - pub chunks: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum WorldFormat { - /// A save directory with `level.dat`, converted to Polar by the Gradle build. - Anvil, - Polar, -} - -#[derive(Debug, Serialize)] -pub struct ChunkRange { - pub from: [i32; 2], - pub to: [i32; 2], } #[derive(Debug)] @@ -51,7 +33,7 @@ pub struct Pack { } /// The `assets/` directory a declaration's sources are relative to, and its project-relative path. Sources must -/// exist unless `require_sources` is false, which classifies a missing world by its `chunks` and extension. +/// exist unless `require_sources` is false. pub(super) struct Root<'a> { pub directory: &'a Path, pub prefix: &'a str, @@ -65,32 +47,17 @@ pub(super) fn worlds(file: &Path, expression: &Expression<'_>, root: &Root<'_>) return Err(invalid(file, "world names must be ASCII identifiers")); } let fields = object_fields(file, value)?; - if fields.keys().any(|key| !["source", "chunks"].contains(key)) { + if fields.keys().any(|key| *key != "source") { return Err(invalid(file, format!("unsupported option of world {name}"))); } - let (source, mut found) = source(file, fields.get("source").copied(), root)?; - if found.is_none() && !root.require_sources { - let path = root.directory.join(source.strip_prefix(root.prefix).unwrap_or(&source).trim_start_matches('/')); - found = fs::symlink_metadata(&path).ok().map(|metadata| (path, metadata)); - } - let chunks = fields.get("chunks").map(|value| chunk_range(file, value)).transpose()?; - let format = match found { - Some((_, metadata)) if metadata.is_file() && extension(&source, "polar") => WorldFormat::Polar, - Some((path, metadata)) - if metadata.is_dir() && fs::symlink_metadata(path.join("level.dat")).is_ok_and(|l| l.is_file()) => - { - WorldFormat::Anvil - } - Some(_) => { - return Err(invalid(file, format!("world {name} requires a .polar file or an Anvil save directory"))); - } - None if chunks.is_none() && extension(&source, "polar") => WorldFormat::Polar, - None => WorldFormat::Anvil, - }; - if chunks.is_some() && format == WorldFormat::Polar { - return Err(invalid(file, format!("world {name} can only crop Anvil saves to chunks"))); + let (source, found) = source(file, fields.get("source").copied(), root)?; + if !extension(&source, "polar") || found.is_some_and(|(_, metadata)| !metadata.is_file()) { + return Err(invalid( + file, + format!("world {name} must be a .polar file; Chunk no longer converts Anvil saves"), + )); } - worlds.insert(name.into(), World { source, format, chunks }); + worlds.insert(name.into(), World { source }); } Ok(worlds) } @@ -161,39 +128,6 @@ fn extension(source: &str, expected: &str) -> bool { Path::new(source).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) } -fn chunk_range(file: &Path, expression: &Expression<'_>) -> io::Result { - let error = || invalid(file, "chunks requires literal { from: [x, z], to: [x, z] } with from <= to"); - let fields = object_fields(file, expression)?; - if fields.len() != 2 { - return Err(error()); - } - let corner = |key| -> io::Result<[i32; 2]> { - let Some(Expression::ArrayExpression(array)) = fields.get(key) else { return Err(error()) }; - let values = array - .elements - .iter() - .map(|element| element.as_expression().and_then(integer)) - .collect::>>() - .ok_or_else(error)?; - values.try_into().map_err(|_| error()) - }; - let (from, to) = (corner("from")?, corner("to")?); - if from[0] > to[0] || from[1] > to[1] { - return Err(error()); - } - Ok(ChunkRange { from, to }) -} - -fn integer(expression: &Expression<'_>) -> Option { - match expression { - Expression::NumericLiteral(number) => number.raw.as_ref()?.parse().ok(), - Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::UnaryNegation => { - integer(&unary.argument)?.checked_neg() - } - _ => None, - } -} - /// Rejects pack names declared twice anywhere in the project. pub(super) fn check_unique_packs(root: &Path, inventory: &Inventory) -> io::Result<()> { let mut names = BTreeSet::new(); diff --git a/crates/chunk-build/src/project/tests.rs b/crates/chunk-build/src/project/tests.rs index 2a58fe95..c8b0a9c9 100644 --- a/crates/chunk-build/src/project/tests.rs +++ b/crates/chunk-build/src/project/tests.rs @@ -220,7 +220,7 @@ fn asset_declarations_resolve_sources_inside_assets_and_order_packs_by_scope() { write("apps/scope.ts", "export default defineScope({packs:{base:{source:'packs/base'}}});"); write("apps/games/scope.ts", "export default defineScope({packs:{games:{source:'games.zip',prompt:'Games'}}});"); write("apps/games/arena/build.gradle.kts", ""); - let app = "export default defineApp({id:'arena',worlds:{koth:{source:'worlds/koth',chunks:{from:[-8,-8],to:[7,7]}},lobby:{source:'lobby.polar'}},packs:{ui:{source:'ui',required:true},hud:{source:'hud'}}});"; + let app = "export default defineApp({id:'arena',worlds:{koth:{source:'worlds/koth.polar'},lobby:{source:'lobby.polar'}},packs:{ui:{source:'ui',required:true},hud:{source:'hud'}}});"; write("apps/games/arena/app.ts", app); for path in [ "assets/packs/base/pack.mcmeta", @@ -231,14 +231,14 @@ fn asset_declarations_resolve_sources_inside_assets_and_order_packs_by_scope() { } write("assets/games.zip", ""); write("apps/games/arena/assets/lobby.polar", ""); - write("apps/games/arena/assets/worlds/koth/level.dat", ""); + write("apps/games/arena/assets/worlds/koth.polar", ""); + fs::create_dir_all(root.join("apps/games/arena/assets/save.polar")).unwrap(); let metadata = serde_json::to_value(inspect(root).unwrap()).unwrap(); assert_eq!( metadata["apps"][0]["worlds"], json!({ - "koth": {"source": "apps/games/arena/assets/worlds/koth", "format": "anvil", - "chunks": {"from": [-8, -8], "to": [7, 7]}}, - "lobby": {"source": "apps/games/arena/assets/lobby.polar", "format": "polar"} + "koth": {"source": "apps/games/arena/assets/worlds/koth.polar"}, + "lobby": {"source": "apps/games/arena/assets/lobby.polar"} }) ); let contract = assets::contract(&inspect_inventory(root).unwrap()); @@ -249,10 +249,18 @@ fn asset_declarations_resolve_sources_inside_assets_and_order_packs_by_scope() { for (from, to, expected) in [ ("source:'lobby.polar'", "source:'../lobby.polar'", "relative path inside apps/games/arena/assets/"), ("source:'lobby.polar'", "source:'missing.polar'", "missing.polar"), - ("source:'lobby.polar'", "source:'worlds'", "requires a .polar file or an Anvil save"), - ("source:'lobby.polar'", "source:'lobby.polar',chunks:{from:[0,0],to:[1,1]}", "only crop Anvil"), - ("from:[-8,-8]", "from:[8,-8]", "from <= to"), - ("source:'ui'", "source:'worlds/koth'", "pack.mcmeta or a .zip"), + ( + "source:'lobby.polar'", + "source:'worlds'", + "world lobby must be a .polar file; Chunk no longer converts Anvil", + ), + ("source:'lobby.polar'", "source:'save.polar'", "world lobby must be a .polar file"), + ( + "source:'lobby.polar'", + "source:'lobby.polar',chunks:{from:[0,0],to:[1,1]}", + "unsupported option of world lobby", + ), + ("source:'ui'", "source:'worlds/koth.polar'", "pack.mcmeta or a .zip"), ("ui:{", "base:{", "pack base is declared more than once"), ] { write("apps/games/arena/app.ts", &app.replace(from, to)); @@ -286,19 +294,11 @@ fn declarations_can_be_inspected_before_their_sources_exist() { assert!(inspect(root).is_err()); let metadata = inspect_declarations(root).unwrap(); let lobby = &metadata.apps[0]; - assert_eq!(lobby.worlds["main"].format, WorldFormat::Polar); assert_eq!(lobby.worlds["main"].source, "apps/lobby/assets/main.polar"); assert_eq!(lobby.packs["ui"].source, "apps/lobby/assets/ui.zip"); - write( - "apps/lobby/app.ts", - &app.replace("main.polar", "save.polar") - .replace("{source:'save.polar'}", "{source:'save.polar',chunks:{from:[0,0],to:[1,1]}}"), - ); - assert_eq!(inspect_declarations(root).unwrap().apps[0].worlds["main"].format, WorldFormat::Anvil); - fs::create_dir_all(root.join("apps/lobby/assets/save.polar")).unwrap(); - write("apps/lobby/assets/save.polar/level.dat", ""); - assert_eq!(inspect_declarations(root).unwrap().apps[0].worlds["main"].format, WorldFormat::Anvil); + write("apps/lobby/app.ts", &app.replace("main.polar", "worlds/main")); + assert!(inspect_declarations(root).unwrap_err().to_string().contains("must be a .polar file")); write("apps/lobby/app.ts", &app.replace("main.polar", "../main.polar")); assert!(inspect_declarations(root).unwrap_err().to_string().contains("relative path inside")); diff --git a/crates/chunk-cli/README.md b/crates/chunk-cli/README.md index 8621f91c..917062a0 100644 --- a/crates/chunk-cli/README.md +++ b/crates/chunk-cli/README.md @@ -203,14 +203,13 @@ environment holds up to 256 secrets. `chunk secrets list` shows names and versio An asset revision is an immutable manifest of the project's worlds, resource packs and files by content digest, which every build writes beside its release; a deployment pins one release and one revision, so assets change without -rebuilding the release. `chunk assets push` builds the project as `chunk dev` does, so Anvil worlds are converted to -Polar, uploads the blobs the project lacks, and moves the project's head to the new revision. It records the revision as -the base in `.chunk/assets.json`, and refuses when someone else moved the head since, until you pull; `--force` replaces -the head anyway. `chunk assets pull` fetches the head and updates each local file whose source is one file (plain files, -`.polar` worlds and `.zip` packs): a file you haven't changed since the base is replaced or deleted, and a file you -changed is left as it is and reported as a conflict, which your next push publishes. Entries built from a directory, -Anvil worlds and pack directories, are listed but never pulled. `chunk assets deploy --env E` deploys the environment's -active release with the head revision, or the one `--revision ID` names. +rebuilding the release. `chunk assets push` builds the project as `chunk dev` does, uploads the blobs the project lacks, +and moves the project's head to the new revision. It records the revision as the base in `.chunk/assets.json`, and +refuses when someone else moved the head since, until you pull; `--force` replaces the head anyway. `chunk assets pull` +fetches the head and updates each local file whose source is one file (plain files, `.polar` worlds and `.zip` packs): a +file you haven't changed since the base is replaced or deleted, and a file you changed is left as it is and reported as +a conflict, which your next push publishes. Pack directories are listed but never pulled. `chunk assets deploy --env E` +deploys the environment's active release with the head revision, or the one `--revision ID` names. `chunk domains add HOSTNAME --env E` claims a custom hostname and prints its state and the DNS records to create: a TXT ownership proof and an SRV record that routes players to the environment. A hostname routes only once it is verified: diff --git a/crates/chunk-cli/src/platform/assets/plan.rs b/crates/chunk-cli/src/platform/assets/plan.rs index 67595885..93f8ae57 100644 --- a/crates/chunk-cli/src/platform/assets/plan.rs +++ b/crates/chunk-cli/src/platform/assets/plan.rs @@ -6,7 +6,7 @@ use std::{ path::{Path, PathBuf}, }; -use chunk_build::project::{ProjectMetadata, WorldFormat}; +use chunk_build::project::ProjectMetadata; use chunk_contract::AssetRevision; /// One world, pack or file of a revision. @@ -142,9 +142,11 @@ pub(super) fn locate(entry: &Entry, root: &Path, metadata: &ProjectMetadata) -> Entry::World(id, name) => { let world = app(id)?.worlds.get(name).ok_or_else(|| format!("{id} declares no world {name} in this project"))?; - match world.format { - WorldFormat::Polar => Ok(root.join(&world.source)), - WorldFormat::Anvil => Err(format!("built from the Anvil save {}", world.source)), + let path = root.join(&world.source); + // A missing source is created by the pull; an existing one must be a file. + match std::fs::symlink_metadata(&path) { + Ok(metadata) if !metadata.is_file() => Err(format!("world source {} is not a file", world.source)), + _ => Ok(path), } } Entry::Pack(name) => { diff --git a/crates/chunk-cli/src/platform/assets/tests.rs b/crates/chunk-cli/src/platform/assets/tests.rs index bfdfa5eb..7a8493c0 100644 --- a/crates/chunk-cli/src/platform/assets/tests.rs +++ b/crates/chunk-cli/src/platform/assets/tests.rs @@ -1,6 +1,8 @@ use super::ensure_inside; -use super::plan::{Pull, Push, check_push, decide, entries, summary}; +use super::plan::{Entry, Pull, Push, check_push, decide, entries, locate, summary}; +use chunk_build::project::{AppMetadata, ProjectMetadata, RuntimeRequirements, World}; use chunk_contract::{AssetBlob, AssetRevision}; +use std::collections::BTreeMap; #[test] fn a_pull_never_overwrites_a_modified_file() { @@ -58,3 +60,35 @@ fn a_pull_never_writes_through_a_symlink() { assert!(error.to_string().contains("symlink")); assert!(ensure_inside(root.path(), &root.path().join("assets/../../x")).is_err()); } + +#[test] +fn a_pull_skips_a_world_whose_source_is_not_a_file() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("apps/arena/assets/save.polar")).unwrap(); + let world = |source: &str| World { source: source.into() }; + let app = AppMetadata { + id: "arena".into(), + directory: "apps/arena".into(), + gradle_project: ":arena".into(), + domain: String::new(), + runtime: RuntimeRequirements::default(), + sessions: BTreeMap::default(), + worlds: [ + ("save".into(), world("apps/arena/assets/save.polar")), + ("lobby".into(), world("apps/arena/assets/lobby.polar")), + ] + .into(), + packs: BTreeMap::default(), + }; + let metadata = ProjectMetadata { version: 1, apps: vec![app], local: None, scope_packs: BTreeMap::default() }; + let locate = |entry: Entry| locate(&entry, root.path(), &metadata); + assert_eq!( + locate(Entry::World("arena".into(), "save".into())).unwrap_err(), + "world source apps/arena/assets/save.polar is not a file" + ); + assert_eq!( + locate(Entry::World("arena".into(), "lobby".into())).unwrap(), + root.path().join("apps/arena/assets/lobby.polar") + ); + assert_eq!(locate(Entry::Shared("a.txt".into())).unwrap(), root.path().join("assets/a.txt")); +} diff --git a/docs/distribution.md b/docs/distribution.md index 6c81083d..5a24c520 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -8,9 +8,9 @@ How the chunk SDK is packaged, installed and published. A release of the SDK has TypeScript SDK, the project templates, the Gradle wrapper and the version pins that `chunk create` writes. - **The JVM artifacts**, published to `https://maven.chunkzero.com` (nightlies to `https://maven.chunkzero.com/nightlies`) under `com.chunkzero.chunk`: `proto`, `backend-api`, `backend-client`, - `backend-client-kotlin`, `runtime`, `multistom`, `multistom-kotlin`, `minestom`, `world-converter`, the Gradle plugin - and its two plugin markers. Each library has `-sources.jar` and `-javadoc.jar` (Dokka HTML) artifacts. Projects - resolve these through Gradle; the CLI archives contain no JARs. + `backend-client-kotlin`, `runtime`, `multistom`, `multistom-kotlin`, `minestom`, the Gradle plugin and its two plugin + markers. Each library has `-sources.jar` and `-javadoc.jar` (Dokka HTML) artifacts. Projects resolve these through + Gradle; the CLI archives contain no JARs. Linux archives are built on Ubuntu 24.04 and need its glibc and OpenSSL 3; macOS and Windows archives link OpenSSL statically. Users need Java to run Gradle; the Minestom runtime needs Java 25, which the generated projects download diff --git a/jvm/gradle-plugin/README.md b/jvm/gradle-plugin/README.md index 7ae02cdc..1e3afc93 100644 --- a/jvm/gradle-plugin/README.md +++ b/jvm/gradle-plugin/README.md @@ -145,13 +145,7 @@ run as. The plugin runs that CLI; it never builds Rust tools or installs Node pa Java app has no Kotlin dependency. 4. Each app applies `application` and Shadow, compiles, and is indexed from its bytecode (below). Its `shadowJar` is an executable JAR with every dependency. -5. Each app's Anvil worlds (`format: "anvil"` in `chunk inspect`) are converted to - `.chunk/build/worlds//.polar` by `convertChunkWorld_`, cropped to the world's `chunks` if given. The - converter, `com.chunkzero.chunk:world-converter` at the plugin's version, runs in the app's Java toolchain on the - app's runtime classpath, leaving out the build's own projects so code changes don't reconvert. It uses the app's - Minestom, upstream or multistom, and the app's Polar if it has one. It rejects a save whose data version isn't that - Minestom's Minecraft version. The conversion is cacheable and its output is the same bytes for the same save. -6. `chunkArtifacts`, on the root, builds every app and writes `.chunk/build/jvm/artifacts.json`: each app's JAR, session +5. `chunkArtifacts`, on the root, builds every app and writes `.chunk/build/jvm/artifacts.json`: each app's JAR, session types and Java version, and the Java executable of the newest toolchain. `chunk build` runs this task and packages the result into a release. `chunk dev` passes `-Pchunk.dev=true`, which skips the shadow JAR and lists each app's thin JAR and runtime classpath instead. diff --git a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ChunkPlugin.kt b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ChunkPlugin.kt index 08a9033c..bf7b4667 100644 --- a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ChunkPlugin.kt +++ b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ChunkPlugin.kt @@ -35,7 +35,6 @@ class ChunkPlugin : Plugin { } val app = configuration.apps.find { it.projectPath == project.path } configureModule(project, app?.id.orEmpty()) - if (app != null) configureWorlds(project, configuration.directory, app) } } diff --git a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ConvertAnvilWorld.kt b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ConvertAnvilWorld.kt deleted file mode 100644 index cc76b67f..00000000 --- a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ConvertAnvilWorld.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.chunkzero.chunk.gradle - -import org.gradle.api.DefaultTask -import org.gradle.api.Project -import org.gradle.api.artifacts.component.ProjectComponentIdentifier -import org.gradle.api.attributes.Bundling -import org.gradle.api.attributes.Category -import org.gradle.api.attributes.LibraryElements -import org.gradle.api.attributes.Usage -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.plugins.JavaPluginExtension -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.CacheableTask -import org.gradle.api.tasks.Classpath -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.Nested -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction -import org.gradle.jvm.toolchain.JavaLauncher -import org.gradle.jvm.toolchain.JavaToolchainService -import org.gradle.process.ExecOperations -import java.io.File -import javax.inject.Inject - -/** Converts an Anvil save to Polar with Chunk's world converter on the app's runtime classpath. */ -@CacheableTask -abstract class ConvertAnvilWorld : DefaultTask() { - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val source: DirectoryProperty - - /** `[fromX, fromZ, toX, toZ]`, inclusive, or empty for the whole save. */ - @get:Input - abstract val chunks: ListProperty - - @get:Classpath - abstract val classpath: ConfigurableFileCollection - - @get:Nested - abstract val launcher: Property - - @get:OutputFile - abstract val output: RegularFileProperty - - @get:Inject - abstract val exec: ExecOperations - - @TaskAction - fun convert() { - exec.javaexec { - executable = - launcher - .get() - .executablePath.asFile.absolutePath - classpath = this@ConvertAnvilWorld.classpath - mainClass.set("com.chunkzero.chunk.worldconverter.AnvilConverter") - jvmArgs("--enable-native-access=ALL-UNNAMED") - args(source.get().asFile.absolutePath, output.get().asFile.absolutePath) - args(chunks.get()) - } - } -} - -/** - * Converts each of the app's Anvil worlds to `.chunk/build/worlds//.polar`, as part of - * `chunkArtifacts`. The converter, `com.chunkzero.chunk:world-converter` at the plugin's version, - * runs on the app's resolved runtime classpath without the build's own projects, so it uses the - * app's Minestom (upstream or multistom) and app code changes don't reconvert. Its Polar resolves - * to the app's version when the app has one. - */ -internal fun configureWorlds( - project: Project, - directory: File, - app: AppMetadata, -) { - if (app.anvilWorlds.isEmpty()) return - val runtimeClasspath = project.configurations.named("runtimeClasspath") - val converter = - project.configurations.detachedConfiguration(project.dependencies.create(framework("world-converter"))).apply { - shouldResolveConsistentlyWith(runtimeClasspath.get()) - attributes { - attribute(Usage.USAGE_ATTRIBUTE, project.objects.named(Usage::class.java, Usage.JAVA_RUNTIME)) - attribute(Category.CATEGORY_ATTRIBUTE, project.objects.named(Category::class.java, Category.LIBRARY)) - attribute( - LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, - project.objects.named(LibraryElements::class.java, LibraryElements.JAR), - ) - attribute(Bundling.BUNDLING_ATTRIBUTE, project.objects.named(Bundling::class.java, Bundling.EXTERNAL)) - } - } - val runtime = - runtimeClasspath.map { configuration -> - configuration.incoming - .artifactView { - componentFilter { it !is ProjectComponentIdentifier || it.build.buildPath != ":" } - }.files - } - val java = project.extensions.getByType(JavaPluginExtension::class.java) - val launcher = project.extensions.getByType(JavaToolchainService::class.java).launcherFor(java.toolchain) - app.anvilWorlds.forEach { world -> - val conversion = - project.tasks.register("convertChunkWorld_${world.name}", ConvertAnvilWorld::class.java) { - group = "chunk" - description = "Convert the Anvil world ${world.name} to Polar" - source.set(directory.resolve(world.source)) - chunks.set(world.chunks) - classpath.from(converter, runtime) - this.launcher.set(launcher) - output.set(directory.resolve(".chunk/build/worlds/${app.id}/${world.name}.polar")) - } - project.rootProject.tasks.named("chunkArtifacts") { dependsOn(conversion) } - } -} diff --git a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ProjectInspection.kt b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ProjectInspection.kt index 0ef581f4..09bb29a8 100644 --- a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ProjectInspection.kt +++ b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ProjectInspection.kt @@ -38,14 +38,6 @@ internal data class AppMetadata( val id: String, val directory: String, val projectPath: String, - val anvilWorlds: List = emptyList(), -) - -/** An app's Anvil world, converted to Polar; [chunks] is `[fromX, fromZ, toX, toZ]` or empty. */ -internal data class AnvilWorld( - val name: String, - val source: String, - val chunks: List, ) internal data class BuildConfiguration( @@ -76,22 +68,7 @@ internal fun readApps( projectPath == segments.joinToString(":", prefix = ":"), ) { "App inventory mapping mismatch: $id" } require(directory.resolve(path).isDirectory) { "App directory missing: $path" } - AppMetadata( - id, - path, - projectPath, - app["worlds"]?.asJsonObject?.entrySet().orEmpty().mapNotNull { (name, value) -> - val world = value.asJsonObject - if (world["format"]?.asString != "anvil") return@mapNotNull null - require(name.matches(Regex("[A-Za-z_][A-Za-z0-9_]{0,127}"))) { "Invalid world name: $name" } - val source = requireNotNull(world["source"]?.asString) { "World source missing: $id/$name" } - val chunks = - world["chunks"]?.asJsonObject?.let { range -> - listOf("from", "to").flatMap { end -> range.getAsJsonArray(end).map { it.asInt } } - } - AnvilWorld(name, source, chunks.orEmpty()) - }, - ) + AppMetadata(id, path, projectPath) } require(apps.map { it.id.lowercase() }.distinct().size == apps.size) { "Duplicate app IDs in chunk inspect" } require(apps.map { it.projectPath.lowercase() }.distinct().size == apps.size) { diff --git a/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ProjectInspectionTest.kt b/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ProjectInspectionTest.kt index 8b6f3f0c..29f8c0bb 100644 --- a/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ProjectInspectionTest.kt +++ b/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ProjectInspectionTest.kt @@ -21,26 +21,6 @@ class ProjectInspectionTest { assertEquals(AppMetadata("arena", "apps/games/duels", ":apps:games:duels"), apps.single()) } - @Test - fun `only Anvil worlds are converted, with their chunk range`() { - directory.resolve("apps/arena").toFile().mkdirs() - val worlds = - readApps( - """{"version":1,"apps":[{"id":"arena","directory":"apps/arena","gradle_project":":apps:arena","worlds":{ - "hub":{"source":"apps/arena/assets/worlds/hub","format":"anvil","chunks":{"from":[-8,-8],"to":[7,7]}}, - "koth":{"source":"apps/arena/assets/worlds/koth","format":"anvil"}, - "lobby":{"source":"apps/arena/assets/worlds/lobby.polar","format":"polar"}}}]}""", - directory.toFile(), - ).single().anvilWorlds - assertEquals( - listOf( - AnvilWorld("hub", "apps/arena/assets/worlds/hub", listOf(-8, -8, 7, 7)), - AnvilWorld("koth", "apps/arena/assets/worlds/koth", emptyList()), - ), - worlds, - ) - } - @Test fun `app inventory rejects mismatched mappings and duplicate physical projects`() { directory.resolve("apps/duels").toFile().mkdirs() diff --git a/jvm/world-converter/build.gradle.kts b/jvm/world-converter/build.gradle.kts deleted file mode 100644 index 52cab350..00000000 --- a/jvm/world-converter/build.gradle.kts +++ /dev/null @@ -1,41 +0,0 @@ -plugins { - id("chunk.java-conventions") - id("chunk.publishing-conventions") -} -java { toolchain.languageVersion = JavaLanguageVersion.of(25) } -tasks.withType().configureEach { options.release = 25 } - -// The Gradle plugin runs the converter on an app's runtime classpath, which supplies the app's -// Minestom: upstream or multistom. It compiles against upstream and is tested on both. -dependencies { - compileOnly(libs.minestom) - compileOnly(libs.jetbrains.annotations) - implementation(libs.polar) - testImplementation(libs.minestom) -} - -val multistomTestRuntime = - configurations.resolvable("multistomTestRuntimeClasspath") { - extendsFrom(configurations.testImplementation.get(), configurations.testRuntimeOnly.get()) - exclude(group = "net.minestom", module = "minestom") - attributes { - attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) - attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY)) - attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.JAR)) - attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL)) - } - } -dependencies { "multistomTestRuntimeClasspath"(libs.multistom) } - -val multistomTest = - tasks.register("multistomTest") { - description = "Runs the tests against multistom." - group = "verification" - testClassesDirs = - sourceSets.test - .get() - .output.classesDirs - classpath = sourceSets.test.get().output + sourceSets.main.get().output + multistomTestRuntime.get() - } -tasks.check { dependsOn(multistomTest) } -tasks.withType().configureEach { jvmArgs("--enable-native-access=ALL-UNNAMED") } diff --git a/jvm/world-converter/src/main/java/com/chunkzero/chunk/worldconverter/AnvilConverter.java b/jvm/world-converter/src/main/java/com/chunkzero/chunk/worldconverter/AnvilConverter.java deleted file mode 100644 index 485eec2c..00000000 --- a/jvm/world-converter/src/main/java/com/chunkzero/chunk/worldconverter/AnvilConverter.java +++ /dev/null @@ -1,297 +0,0 @@ -package com.chunkzero.chunk.worldconverter; - -import net.hollowcube.polar.PolarChunk; -import net.hollowcube.polar.PolarDataConverter; -import net.hollowcube.polar.PolarSection; -import net.hollowcube.polar.PolarWorld; -import net.hollowcube.polar.PolarWriter; -import net.kyori.adventure.nbt.BinaryTagIO; -import net.minestom.server.instance.Chunk; -import net.minestom.server.instance.anvil.AnvilLoader; -import net.minestom.server.instance.block.Block; -import net.minestom.server.instance.light.LightCompute; -import net.minestom.server.registry.DynamicRegistry; -import net.minestom.server.world.DimensionType; -import net.minestom.server.world.biome.Biome; - -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.IntFunction; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Converts an Anvil save's overworld to a Polar world for the Gradle plugin, with the Minestom - * (upstream or multistom) on the classpath. The same save converts to the same bytes. - */ -@ApiStatus.Internal -public final class AnvilConverter { - private static final Pattern REGION = Pattern.compile("r\\.(-?\\d+)\\.(-?\\d+)\\.mca"); - private static final int MIN_SECTION = -4; - private static final int MAX_SECTION = 19; - // PolarWriter reads this many heightmap slots; the runtime computes heightmaps itself. - private static final int HEIGHTMAPS = 32; - private static final PolarDataConverter VERSION = - new PolarDataConverter() { - @Override - public int dataVersion() { - return Engine.DATA_VERSION; - } - }; - - private AnvilConverter() {} - - /** {@code [ ]}, inclusive chunk coordinates. */ - public static void main(String[] args) throws IOException { - if (args.length != 2 && args.length != 6) - throw new IllegalArgumentException( - "Usage: AnvilConverter [ ]"); - var range = - args.length == 6 - ? Arrays.stream(args, 2, 6).mapToInt(Integer::parseInt).toArray() - : null; - byte[] polar; - try { - polar = convert(Path.of(args[0]), range); - } catch (IllegalArgumentException error) { - System.err.println(error.getMessage()); - System.exit(1); - return; - } - var output = Path.of(args[1]).toAbsolutePath(); - Files.createDirectories(output.getParent()); - Files.write(output, polar); - } - - /** - * Converts the full chunks of {@code save}, within {@code range} ({@code fromX, fromZ, toX, - * toZ}) if given. - * - * @throws IllegalArgumentException if the save is unreadable, from another Minecraft version or - * has no chunks to convert - */ - static byte[] convert(Path save, int @Nullable [] range) throws IOException { - checkVersion(save.resolve("level.dat"), save); - var regions = save.resolve("dimensions/minecraft/overworld/region"); - if (!Files.isDirectory(regions)) { - throw new IllegalArgumentException( - Files.isDirectory(save.resolve("region")) - ? save + " was saved before Minecraft 26.1. " + upgrade() - : save + " has no dimensions/minecraft/overworld/region directory"); - } - var chunks = new ArrayList(); - try (var engine = - new Engine( - error -> { - throw new IllegalArgumentException(error.getMessage(), error); - })) { - var instance = engine.overworld(); - var loader = new AnvilLoader(save, DimensionType.OVERWORLD.key()); - for (var region : regions(regions)) { - for (int z = 0; z < 32; z++) { - for (int x = 0; x < 32; x++) { - int chunkX = region[0] * 32 + x, chunkZ = region[1] * 32 + z; - if (range != null && !inside(range, chunkX, chunkZ)) continue; - Chunk chunk; - try { - chunk = loader.loadChunk(instance, chunkX, chunkZ); - } catch (IllegalArgumentException error) { - throw new IllegalArgumentException( - "Could not read chunk %d, %d of %s: %s" - .formatted(chunkX, chunkZ, save, error.getMessage()), - error); - } - if (chunk == null) continue; - var data = chunk.tagHandler().asCompound(); - var status = data.getString("status"); - if (status.isEmpty() || status.equals("minecraft:full")) { - check( - data.getInt("DataVersion"), - "Chunk %d, %d of %s".formatted(chunkX, chunkZ, save)); - chunk.lockReadLock(); - try { - chunks.add(polar(engine.process().registries().biome(), chunk)); - } finally { - chunk.unlockReadLock(); - } - } - loader.unloadChunk(chunk); - } - } - } - } - if (chunks.isEmpty()) - throw new IllegalArgumentException( - save - + " has no generated chunks" - + (range == null ? "" : " in its chunk range")); - var world = - new PolarWorld( - PolarWorld.LATEST_VERSION, - Engine.DATA_VERSION, - PolarWorld.CompressionType.ZSTD, - (byte) MIN_SECTION, - (byte) MAX_SECTION, - new byte[0], - chunks); - return PolarWriter.write(world, VERSION); - } - - private static void checkVersion(Path level, Path save) throws IOException { - if (!Files.isRegularFile(level)) return; - try (var input = Files.newInputStream(level)) { - var data = - BinaryTagIO.unlimitedReader() - .read(input, BinaryTagIO.Compression.GZIP) - .getCompound("Data"); - check(data.getInt("DataVersion"), save.toString()); - } - } - - private static void check(int version, String subject) { - if (version != Engine.DATA_VERSION) - throw new IllegalArgumentException( - "%s has data version %d, but the runtime targets Minecraft %s (data version %d). %s" - .formatted( - subject, - version, - Engine.VERSION_NAME, - Engine.DATA_VERSION, - upgrade())); - } - - private static String upgrade() { - return "Open it in Minecraft " - + Engine.VERSION_NAME - + ", save it and build again; Optimize World upgrades every chunk."; - } - - /** Region coordinates of the {@code r...mca} files, in order. */ - private static List regions(Path directory) throws IOException { - try (var files = Files.list(directory)) { - return files.map(file -> REGION.matcher(file.getFileName().toString())) - .filter(Matcher::matches) - .map( - match -> - new int[] { - Integer.parseInt(match.group(1)), - Integer.parseInt(match.group(2)) - }) - .sorted( - Comparator.comparingInt(region -> region[0]) - .thenComparingInt(region -> region[1])) - .toList(); - } - } - - private static boolean inside(int[] range, int x, int z) { - return x >= Math.min(range[0], range[2]) - && x <= Math.max(range[0], range[2]) - && z >= Math.min(range[1], range[3]) - && z <= Math.max(range[1], range[3]); - } - - private static PolarChunk polar(DynamicRegistry biomes, Chunk chunk) { - var sections = new PolarSection[MAX_SECTION - MIN_SECTION + 1]; - var blockEntities = new ArrayList(); - for (int i = 0; i < sections.length; i++) { - int sectionY = MIN_SECTION + i; - var section = chunk.getSection(sectionY); - var blocks = new Palette(id -> Block.fromStateId(id).state()); - int[] blockData = null; - if (section.blockPalette().count() == 0) { - blocks.index(Block.AIR.stateId()); - } else { - var data = new int[PolarSection.BLOCK_PALETTE_SIZE]; - section.blockPalette() - .getAll( - (x, y, z, state) -> - data[y * 256 + z * 16 + x] = blocks.index(state)); - blockData = data; - for (int y = 0; y < Chunk.CHUNK_SECTION_SIZE; y++) { - for (int z = 0; z < Chunk.CHUNK_SIZE_Z; z++) { - for (int x = 0; x < Chunk.CHUNK_SIZE_X; x++) { - int blockY = sectionY * Chunk.CHUNK_SECTION_SIZE + y; - var block = chunk.getBlock(x, blockY, z, Block.Getter.Condition.CACHED); - if (block == null || block.handler() == null && !block.hasNbt()) - continue; - var handler = block.handler(); - blockEntities.add( - new PolarChunk.BlockEntity( - x, - blockY, - z, - handler == null ? null : handler.getKey().asString(), - block.nbt())); - } - } - } - } - var biomeNames = new Palette(id -> biomes.getKey(id).key().asString()); - var biomeData = new int[PolarSection.BIOME_PALETTE_SIZE]; - section.biomePalette() - .getAll((x, y, z, id) -> biomeData[y * 16 + z * 4 + x] = biomeNames.index(id)); - var blockLight = section.blockLight().array(); - var skyLight = section.skyLight().array(); - sections[i] = - new PolarSection( - blocks.names(), - blockData, - biomeNames.names(), - biomeData, - light(blockLight), - blockLight, - light(skyLight), - skyLight); - } - return new PolarChunk( - chunk.getChunkX(), - chunk.getChunkZ(), - sections, - blockEntities, - new int[HEIGHTMAPS][], - new byte[0]); - } - - private static PolarSection.LightContent light(byte[] data) { - if (data.length == 0) return PolarSection.LightContent.MISSING; - if (Arrays.equals(data, LightCompute.EMPTY_CONTENT)) return PolarSection.LightContent.EMPTY; - if (Arrays.equals(data, LightCompute.CONTENT_FULLY_LIT)) - return PolarSection.LightContent.FULL; - return PolarSection.LightContent.PRESENT; - } - - /** A section palette in first-seen order. */ - private static final class Palette { - private final IntFunction name; - private final Map indices = new HashMap<>(); - private final List names = new ArrayList<>(); - - Palette(IntFunction name) { - this.name = name; - } - - int index(int id) { - return indices.computeIfAbsent( - id, - ignored -> { - names.add(name.apply(id)); - return names.size() - 1; - }); - } - - String[] names() { - return names.toArray(String[]::new); - } - } -} diff --git a/jvm/world-converter/src/main/java/com/chunkzero/chunk/worldconverter/Engine.java b/jvm/world-converter/src/main/java/com/chunkzero/chunk/worldconverter/Engine.java deleted file mode 100644 index e313cdfe..00000000 --- a/jvm/world-converter/src/main/java/com/chunkzero/chunk/worldconverter/Engine.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.chunkzero.chunk.worldconverter; - -import net.minestom.server.MinecraftServer; -import net.minestom.server.ServerProcess; -import net.minestom.server.exception.ExceptionHandler; -import net.minestom.server.exception.ExceptionManager; -import net.minestom.server.instance.InstanceContainer; -import net.minestom.server.registry.RegistryKey; -import net.minestom.server.world.DimensionType; - -import java.lang.reflect.InvocationTargetException; -import java.util.UUID; - -/** - * A server process of the Minestom on the classpath. Upstream Minestom has one global process, - * started through {@link MinecraftServer}; multistom creates processes with {@code - * ServerProcess.create()} and passes them to instances. This is compiled against upstream, so - * multistom's API is called reflectively. - */ -final class Engine implements AutoCloseable { - // Read at run time: javac inlines Minestom's constants, and only multistom's class is public. - static final int DATA_VERSION = (Integer) constant("DATA_VERSION"); - static final String VERSION_NAME = (String) constant("VERSION_NAME"); - - private final ServerProcess process; - private final boolean multistom; - - Engine(ExceptionHandler handler) { - multistom = hasMethod("create"); - process = - multistom - ? (ServerProcess) call(ServerProcess.class, "create", null) - : MinecraftServer.updateProcess(); - var exceptions = - multistom - ? (ExceptionManager) call(ServerProcess.class, "exceptionManager", process) - : process.exception(); - exceptions.setExceptionHandler(handler); - } - - ServerProcess process() { - return process; - } - - InstanceContainer overworld() { - if (!multistom) - return new InstanceContainer(process, UUID.randomUUID(), DimensionType.OVERWORLD); - try { - return InstanceContainer.class - .getConstructor(ServerProcess.class, UUID.class, RegistryKey.class) - .newInstance(process, UUID.randomUUID(), DimensionType.OVERWORLD); - } catch (InvocationTargetException error) { - throw rethrow(error); - } catch (ReflectiveOperationException error) { - throw new IllegalStateException("Unsupported Minestom version", error); - } - } - - @Override - public void close() { - process.stop(); - } - - private static Object constant(String name) { - try { - var field = Class.forName("net.minestom.server.MinecraftConstants").getField(name); - field.setAccessible(true); - return field.get(null); - } catch (ReflectiveOperationException error) { - throw new IllegalStateException("Unsupported Minestom version", error); - } - } - - private static boolean hasMethod(String name) { - try { - ServerProcess.class.getMethod(name); - return true; - } catch (NoSuchMethodException error) { - return false; - } - } - - private static Object call(Class type, String method, Object target) { - try { - return type.getMethod(method).invoke(target); - } catch (InvocationTargetException error) { - throw rethrow(error); - } catch (ReflectiveOperationException error) { - throw new IllegalStateException("Unsupported Minestom version", error); - } - } - - private static RuntimeException rethrow(InvocationTargetException error) { - if (error.getCause() instanceof RuntimeException cause) return cause; - if (error.getCause() instanceof Error cause) throw cause; - return new IllegalStateException(error.getCause()); - } -} diff --git a/jvm/world-converter/src/test/java/com/chunkzero/chunk/worldconverter/AnvilConverterTest.java b/jvm/world-converter/src/test/java/com/chunkzero/chunk/worldconverter/AnvilConverterTest.java deleted file mode 100644 index 85fc929a..00000000 --- a/jvm/world-converter/src/test/java/com/chunkzero/chunk/worldconverter/AnvilConverterTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.chunkzero.chunk.worldconverter; - -import static org.junit.jupiter.api.Assertions.*; - -import net.hollowcube.polar.PolarReader; -import net.kyori.adventure.nbt.BinaryTagIO; -import net.kyori.adventure.nbt.CompoundBinaryTag; -import net.minestom.server.instance.DynamicChunk; -import net.minestom.server.instance.anvil.AnvilLoader; -import net.minestom.server.instance.block.Block; -import net.minestom.server.world.DimensionType; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; - -class AnvilConverterTest { - private static final Block STAIRS = Block.OAK_STAIRS.withProperty("facing", "east"); - - @TempDir Path save; - - @Test - void aSaveConvertsToTheSameBytesEveryTimeWithinItsChunkRange() throws IOException { - try (var engine = - new Engine( - error -> { - throw new AssertionError(error); - })) { - var instance = engine.overworld(); - var loader = new AnvilLoader(save, DimensionType.OVERWORLD.key()); - for (var position : new int[][] {{0, 0}, {1, 0}, {-1, -1}}) { - var chunk = new DynamicChunk(instance, position[0], position[1]); - chunk.lockWriteLock(); - chunk.setBlock(1, 64, 1, STAIRS); - chunk.setBlock(2, 70, 2, Block.STONE); - chunk.unlockWriteLock(); - loader.saveChunk(chunk); - loader.unloadChunk(chunk); - } - } - var polar = AnvilConverter.convert(save, null); - assertArrayEquals(polar, AnvilConverter.convert(save, null)); - var world = PolarReader.read(polar); - assertEquals(3, world.chunks().size()); - assertEquals(Engine.DATA_VERSION, world.dataVersion()); - var section = world.chunkAt(-1, -1).sections()[64 / 16 + 4]; - assertTrue(List.of(section.blockPalette()).contains(STAIRS.state())); - var cropped = PolarReader.read(AnvilConverter.convert(save, new int[] {0, 0, 1, 0})); - assertEquals(2, cropped.chunks().size()); - assertNull(cropped.chunkAt(-1, -1)); - } - - @Test - void savesOfAnotherMinecraftVersionAreRejected() throws IOException { - var data = CompoundBinaryTag.builder().putInt("DataVersion", 4189).build(); - try (var output = Files.newOutputStream(save.resolve("level.dat"))) { - BinaryTagIO.writer() - .writeNamed( - Map.entry("", CompoundBinaryTag.builder().put("Data", data).build()), - output, - BinaryTagIO.Compression.GZIP); - } - var error = - assertThrows( - IllegalArgumentException.class, () -> AnvilConverter.convert(save, null)); - assertTrue( - error.getMessage().contains("Open it in Minecraft " + Engine.VERSION_NAME), - error.getMessage()); - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 479c530e..8680b6a9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,4 +30,3 @@ include(":jvm:runtime") include(":jvm:multistom") include(":jvm:multistom-kotlin") include(":jvm:minestom") -include(":jvm:world-converter")