From 9785b41eda280fa4ee2cddb8b29aec0c9a1f5384 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 6 Nov 2026 14:01:27 +0000 Subject: [PATCH] Give magic mods player pools and more control over nexuses --- CHANGELOG.md | 14 + README.md | 4 +- build.gradle | 2 +- docs/API.md | 10 +- docs/CONFIGURATION.md | 20 +- docs/DEVELOPMENT.md | 8 + docs/MODPACKS.md | 2 + docs/PLAYER_ENERGY.md | 114 +++++ docs/README.md | 2 + docs/SERVER_GUIDE.md | 4 + docs/WORLDGEN_AND_PINS.md | 55 +++ gradle.properties | 2 +- .../moddev/mc/skysleylines/LeyConfig.java | 34 ++ .../moddev/mc/skysleylines/SkysLeyLines.java | 32 +- .../moddev/mc/skysleylines/api/LeyColumn.java | 119 ++++- .../mc/skysleylines/api/LeyFieldSampler.java | 5 + .../mc/skysleylines/api/LeyLinesApi.java | 14 + .../moddev/mc/skysleylines/api/Nexus.java | 18 +- .../moddev/mc/skysleylines/api/NexusId.java | 41 +- .../skysleylines/api/NexusRecoveryEvent.java | 33 ++ .../mc/skysleylines/api/PlayerPowerEvent.java | 36 ++ .../skysleylines/api/PlayerPowerService.java | 90 ++++ .../mc/skysleylines/client/DowsingClient.java | 2 + .../client/EnergyOrbRenderer.java | 40 ++ .../skysleylines/client/LeyConfigSection.java | 22 + .../skysleylines/client/LeyVisionClient.java | 9 +- .../mc/skysleylines/core/LeyNetwork.java | 204 ++++++++- .../mc/skysleylines/core/NexusLedger.java | 154 ++++++- .../core/PlayerEnergyDefaults.java | 27 ++ .../mc/skysleylines/core/VisionGeometry.java | 24 +- .../integration/kubejs/LeyKubeEvents.java | 74 ++++ .../integration/kubejs/LeyKubePlugin.java | 13 + .../integration/kubejs/LeyPlayerScripts.java | 147 +++++++ .../integration/kubejs/LeyServerScripts.java | 2 +- .../skysleylines/network/VisionPayload.java | 7 +- .../mc/skysleylines/server/EnergyJobs.java | 296 +++++++++++++ .../mc/skysleylines/server/EnergyOrb.java | 44 ++ .../mc/skysleylines/server/LeyCommands.java | 12 +- .../skysleylines/server/LeyServerContext.java | 47 +- .../mc/skysleylines/server/LeyWorldData.java | 150 +++++-- .../skysleylines/server/NexusPinCommands.java | 160 +++++++ .../mc/skysleylines/server/NexusPins.java | 104 +++++ .../mc/skysleylines/server/PlayerEnergy.java | 406 ++++++++++++++++++ .../skysleylines/server/PlayerEnergyData.java | 126 ++++++ .../server/PlayerEnergyEvents.java | 37 ++ .../server/PlayerEnergyPolicy.java | 155 +++++++ .../assets/skysleylines/lang/de_de.json | 4 +- .../assets/skysleylines/lang/en_au.json | 4 +- .../assets/skysleylines/lang/en_ca.json | 4 +- .../assets/skysleylines/lang/en_en.json | 4 +- .../assets/skysleylines/lang/en_gb.json | 4 +- .../assets/skysleylines/lang/en_pt.json | 4 +- .../assets/skysleylines/lang/en_us.json | 4 +- .../assets/skysleylines/lang/es_es.json | 4 +- .../assets/skysleylines/lang/es_mx.json | 4 +- .../assets/skysleylines/lang/fr_ca.json | 4 +- .../assets/skysleylines/lang/fr_fr.json | 4 +- .../assets/skysleylines/lang/it_it.json | 4 +- .../assets/skysleylines/lang/ja_jp.json | 4 +- .../assets/skysleylines/lang/nl_nl.json | 4 +- .../assets/skysleylines/lang/pt_br.json | 4 +- .../assets/skysleylines/lang/ru_ru.json | 4 +- .../assets/skysleylines/lang/zh_cn.json | 4 +- .../assets/skysleylines/lang/zh_tw.json | 4 +- .../skysleylines/probe/LeyRuntimeProbe.java | 1 + .../skysleylines/probe/PlayerEnergyProbe.java | 61 +++ .../resources/META-INF/neoforge.mods.toml | 2 +- .../mc/skysleylines/LeyWorldDataTest.java | 47 ++ .../moddev/mc/skysleylines/VisionTest.java | 6 + .../mc/skysleylines/server/NexusPinsTest.java | 163 +++++++ .../skysleylines/server/PlayerEnergyTest.java | 97 +++++ .../probe/ExplorationTelemetry.java | 94 ++++ .../probe/OptionalLeyWorldgenProbe.java | 1 + .../skysleylines/probe/OptionalSampler.java | 22 + .../probe/StructureWorldgenProbe.java | 240 +++++++++++ .../resources/META-INF/neoforge.mods.toml | 2 +- .../worldgen/structure/nexus.json | 6 + .../worldgen/structure_set/nexus.json | 4 + 78 files changed, 3626 insertions(+), 113 deletions(-) create mode 100644 docs/PLAYER_ENERGY.md create mode 100644 docs/WORLDGEN_AND_PINS.md create mode 100644 src/main/java/zone/moddev/mc/skysleylines/api/NexusRecoveryEvent.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/api/PlayerPowerEvent.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/api/PlayerPowerService.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/client/EnergyOrbRenderer.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/core/PlayerEnergyDefaults.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyKubeEvents.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyPlayerScripts.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/server/EnergyJobs.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/server/EnergyOrb.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/server/NexusPinCommands.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/server/NexusPins.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergy.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergyData.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergyEvents.java create mode 100644 src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergyPolicy.java create mode 100644 src/runtimeProbe/java/zone/moddev/mc/skysleylines/probe/PlayerEnergyProbe.java create mode 100644 src/test/java/zone/moddev/mc/skysleylines/server/NexusPinsTest.java create mode 100644 src/test/java/zone/moddev/mc/skysleylines/server/PlayerEnergyTest.java create mode 100644 src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/ExplorationTelemetry.java create mode 100644 src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/StructureWorldgenProbe.java create mode 100644 src/worldgenProbe/resources/data/leyworldgenprobe/worldgen/structure/nexus.json create mode 100644 src/worldgenProbe/resources/data/leyworldgenprobe/worldgen/structure_set/nexus.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d925b..99e2872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.5.5.121012 beta + +I wanted magic mods to be able to share a nexus even when they use completely different kinds of magic. A technomancer can now carry aether and a mage can carry mana. Each mod decides who gets a pool and how big it is, and both can draw from the same nexus. + +- Transfers and conversions can happen instantly or over time. Mods can also let players refill from a nearby nexus at a rate they choose. +- Player energy drops on death by default. Pack makers can change that behavior, tune capacities and rates, and restrict transfers between players. +- KubeJS can read and change pools, start and cancel jobs, and react when a pool runs empty. +- Structure mods can ask for all the nexuses in a chunk from a generation worker. Ore placement can reuse the column sampler. +- Operators can stage named nexuses and their connections for the next world start. A retired pin keeps its converted balances available for recovery at another nexus. +- Nexus pools now default to 1% of their original size. A 7,000-raw nexus holds 70 raw, enough for 700 units at a rate of 10 units per raw. Change **Nexus Capacity Multiplier** in Server Settings and reopen the world to adjust it. Existing converted energy stays available. +- The ledger backs itself up before changing the pool multiplier or first adding player-pool or pin data. Multiplier changes use save schema 6. Use the same build on the client and server. + +This beta is for Minecraft 1.21.1 on NeoForge 21.1.247 or later in the 21.1 line, with Java 21. + ## 0.5.4.121012 beta This beta brings together the work since the 0.4.1 alpha. I’ve added a few ways to see what’s going on in your world and given pack makers more control over how the magic works. diff --git a/README.md b/README.md index 3381f5d..95ce442 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ Craft a pair of dowsing rods with two copper ingots and two sticks. Hold the pai If you’re an operator, run `/skysleylines locate nexus` and click the coordinates to copy them. Switch on Ley Vision with `/skysleylines vision on`. River is the default, with Crackle, Sparkles and Glow for a quieter view. Pick up a Ley Meter from **Operator Utilities** and right-click a block to see the local field and any nexus account you can reach from there. -Open **Mods → Sky’s Ley Lines → Config** to set up new-world defaults. In a local single-player world you can also choose the default vision style and adjust how long a nexus takes to refill. The [operator guide](docs/SERVER_GUIDE.md) covers commands and dedicated servers. +Open **Mods → Sky’s Ley Lines → Config** to set up new-world defaults. In a local single-player world you can also choose the default vision style and adjust how much a nexus can hold and how long it takes to refill. Operators can place a named nexus with `/skysleylines nexus pin add`. It takes effect the next time the world opens. The [operator guide](docs/SERVER_GUIDE.md) covers commands and dedicated servers. ## Build on it -A nexus has one shared reserve. Mods can register their own currencies, convert raw magic into them, and spend those balances on their own effects. Pack makers can do the same with optional KubeJS scripts. Start with the [Java quick start](docs/INTEGRATION.md) or the [pack maker guide](docs/MODPACKS.md). +A nexus has one shared reserve. Mods can register their own currencies, convert raw magic into them, and spend those balances on their own effects. They can also give players personal pools and let them draw from a nexus over time. Pack makers can do the same with optional KubeJS scripts. Start with the [Java quick start](docs/INTEGRATION.md) or the [pack maker guide](docs/MODPACKS.md). The [documentation index](docs/README.md) is also in the mod JAR at `META-INF/skysleylines/docs/README.md`. diff --git a/build.gradle b/build.gradle index 4174d6e..08acc0a 100644 --- a/build.gradle +++ b/build.gradle @@ -112,7 +112,7 @@ tasks.named('processResources', ProcessResources) { tasks.named('jar', Jar) { manifest.attributes('Specification-Title': "Sky's Ley Lines", 'Specification-Version': '2', 'Implementation-Version': project.version, 'SkysLeyLines-API-Version': '2', - 'SkysLeyLines-Layout-Version': '1', 'SkysLeyLines-Save-Schema': '3', + 'SkysLeyLines-Layout-Version': '1', 'SkysLeyLines-Save-Schema': '5', 'SkysLeyLines-Documentation': documentationIndex) } tasks.named('javadoc', Javadoc) { diff --git a/docs/API.md b/docs/API.md index 1167d06..7afd6a8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -51,13 +51,13 @@ var returned = power.returnToRawAt(x, y, z, AETHER, 1000, plant); Every paradigm, player, and machine shares one account per nexus. Consumers may use currencies they understand from any paradigm. Raw magic is a conversion reserve, not a directly spendable currency. There are no per-player wallets or per-currency capacities. -`raw + sum(currency units / unitsPerRaw)` is the total charge. It cannot exceed the strength-dependent nexus capacity. Conversion and return preserve this total; spending reduces it. Recovery adds raw into the remaining space at `capacity / rechargeTicks` per Overworld game tick. It works while chunks are unloaded, without wall-clock/offline recovery or a per-node tick loop. Elapsed recovery is settled before a transaction, preventing retroactive recovery into space that spending just freed. +`raw + sum(currency units / unitsPerRaw)` is the total charge. Conversion and return preserve this total; spending reduces it. A capacity reduction can leave retained energy above the new limit. Such accounts can still convert, spend, and return energy. They cannot recharge or accept deposits until there is room. Recovery adds raw into the remaining space at `capacity / rechargeTicks` per Overworld game tick. It works while chunks are unloaded, without wall-clock/offline recovery or a per-node tick loop. Elapsed recovery is settled before a transaction, preventing retroactive recovery into space that spending just freed. `MagicAmount` is an exact nonnegative rational with `of(long)` and `fraction(long,long)` helpers. Rates must be positive. Currency balances are whole nonnegative signed-long units. Currency IDs and each reduced rate or saved raw component must fit in a 65,535-byte NBT string. Conversion must produce whole currency units. A fractional output returns `NON_INTEGRAL_OUTPUT`, and a result that exceeds a storage limit returns `OVERFLOW`. Both leave balances intact. Returning a currency unit may restore a fractional raw amount exactly. `quoteAt` returns an immutable nexus/account snapshot or empty outside a usable nexus field. `account.raw()`, `account.total()`, and `account.currency(id)` expose exact raw, total raw-equivalent, and currency balances. Missing currency entries mean zero. `rates()` gives the saved effective world rates, including retained inactive currencies. Quotes are projections, not reservations: they create no account, emit no event, and never dirty data. -`LeyLinesApi.currencyInfo(level)` returns an immutable map of saved rates and active flags on the server thread. `NexusSnapshot.rechargeTicks()` reports the effective refill interval, including a server override. `Context.profile().rechargeTicks()` is the base interval saved when the world first initialized. +`LeyLinesApi.currencyInfo(level)` returns an immutable map of saved rates and active flags on the server thread. `NexusSnapshot.rechargeTicks()` reports the effective refill interval, including a server override. `NexusSnapshot.capacity()` and `AccountSnapshot.capacity()` report the effective limit with the server's capacity multiplier. `Context.profile()` contains the original world settings, and `Nexus.capacity(profile)` calculates the base capacity from them. Transactions select the nearest eligible nexus, with canonical identity breaking exact-distance ties. They never split a request or fall through to another nexus when funds are insufficient. Lines alone cannot supply power; horizontal and vertical boundaries apply. Untouched nodes start fully raw. @@ -104,6 +104,8 @@ Reuse a column outside candidate-height loops. `naturalStrengthAt` is allocation For optional integration, check `ModList.get().isLoaded("skysleylines")` before loading a dedicated integration class with API references. Compile production integrations against a matching released artifact and declare a compatible optional dependency. Acquire the sampler during `LevelEvent.Load`; `ServerStartingEvent` runs after spawn preparation on this target. If the optional mod is absent, use the consumer’s ordinary generation rule. The disposable worldgen probe exercises this pattern independently of OreSpawn. +Use `nexusesInChunk(chunkX, chunkZ)` for every centre owned by a chunk, or `LeyLinesApi.worldgenSampler(randomState)` from an actual structure context. See [Worldgen and nexus pins](WORLDGEN_AND_PINS.md) for optional loading, structure placement, and authored anchors. + ## Ley vision grants Since Sky's Ley Lines 0.4, spells, potions, or equipment can grant a player the same local view as the operator command. Call on the server thread with the connected player's `ServerPlayer`. @@ -136,3 +138,7 @@ Use a distinct namespaced source for each effect that should expire independentl The server sends nearby clipped segments and crossings only to the viewer. The client uses terrain already loaded there to place the effect. The display follows the planar field, while exact vertical strength and nexus eligibility come from `LeyFieldSampler`. The visual width, water surface height, and plumes are presentation choices. [Java integration quick start](INTEGRATION.md) · [Operator commands](SERVER_GUIDE.md) · [KubeJS bindings](MODPACKS.md) + +## Player power + +From 0.5.5, use `LeyLinesApi.playerPower(server)` for converted player pools and timed operations. See [Player energy pools](PLAYER_ENERGY.md) for the service, timing, transaction events, and policy overrides. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2146248..8c5999b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,6 +1,6 @@ # Configuration and world continuity -Open **Mods → Sky’s Ley Lines → Config**. **New World Defaults** edits the settings that a world copies when its ley profile is first initialized. **Server Settings** is available in a local single-player world. It lets you pick the default Ley Vision style and set a recharge override. Reopen the world to apply a new recharge interval. The screen edits the server TOML that NeoForge loaded. Worlds using the instance server config share those settings. To make edits specific to one world, place a copy of the TOML in that world’s `serverconfig` folder before opening it. For a dedicated server or a LAN host, edit the server TOML with the world closed. +Open **Mods → Sky’s Ley Lines → Config**. **New World Defaults** edits the settings that a world copies when its ley profile is first initialized. **Server Settings** is available in a local single-player world. It lets you pick the default Ley Vision style and tune nexus capacity and recharge. Reopen the world to apply capacity and recharge changes. The screen edits the server TOML that NeoForge loaded. Worlds using the instance server config share those settings. To make edits specific to one world, place a copy of the TOML in that world’s `serverconfig` folder before opening it. For a dedicated server or a LAN host, edit the server TOML with the world closed. `config/skysleylines-common.toml` contains defaults for NEW worlds. On first server start with the mod, the complete effective profile and version identifiers are saved in `/data/skysleylines.dat`. Existing worlds retain those values even when installation defaults change. `/skysleylines profile` shows the saved values. @@ -21,12 +21,14 @@ The server config supplies these settings for new and existing worlds. | --- | --- | --- | --- | | `defaultVisionStyle` | `"river"` | `"river"`, `"crackle"`, `"sparkles"`, `"glow"` | Operator vision and unstyled effects use the server default. | | `rechargeTicksOverride` | `0` | `0` or an integer from 1 through 1,728,000 | Read when the world opens. Zero uses the saved base interval. | +| `nexusCapacityMultiplier` | `0.01` | `0.0001` through `100` with up to four decimal places | Read when the world opens. `1.0` uses the original capacity. | For example, use the following server TOML for Crackle vision and a three Minecraft day refill. ```toml defaultVisionStyle = "crackle" rechargeTicksOverride = 72000 +nexusCapacityMultiplier = 0.01 ``` ## Version-one landscape @@ -45,17 +47,27 @@ Equipped dowsing rods turn towards the closest ley line within 256 blocks while Set `defaultVisionStyle` in `config/skysleylines-server.toml` to `"river"` (default), `"crackle"`, `"sparkles"`, or `"glow"`. For a single world, put the same file in `/serverconfig/` to override the instance default. Edit the config while the world is closed. The setting applies to operator vision and unstyled grants in existing and new worlds. Styled grants can override it for the viewer while they are active. All four styles are visual overlays. +## Nexus pins + +Admin pins use a restart-applied overlay. See [Worldgen and nexus pins](WORLDGEN_AND_PINS.md) for commands and retirement rules. + +## Player energy policy + +Player pools and timed operations have their own JSON policy layers. See [Player energy pools](PLAYER_ENERGY.md) for data pack and server files, death behavior, and the schema 4 upgrade. + ## Shared accounts and recharge -Each node has one total capacity shared by raw and all currencies. The saved world profile and intrinsic nexus strength determine capacity. Converted balances occupy capacity. Spending frees space for raw recharge, and dowsing rods report total charge, including converted currencies. +Each node has one capacity shared by raw and all currencies. The saved world profile and intrinsic nexus strength determine its base capacity. `nexusCapacityMultiplier` scales that number for natural and authored nexuses. The result rounds to the nearest whole raw unit, with a minimum of 1 and a maximum of 1,000,000. At `0.01`, a nexus with a base capacity of 7,000 holds 70 raw. A child currency at 10 units per raw gets 700 units from that full pool. The default full recharge is one Minecraft day of elapsed Overworld ticks. + +Converted balances occupy capacity. Spending frees space for raw recharge, and dowsing rods report total charge, including converted currencies. When the multiplier drops, the mod trims raw above the new limit after accounting for saved currencies. It preserves every converted unit, even if the account ends up above the new capacity. You can still use that energy. Recharge, deposits and retired-pin recovery into that account wait for room. Raising the multiplier preserves the current balance in stored accounts and lets recharge fill the extra space. Set `rechargeTicksOverride` in `skysleylines-server.toml` to `0` to use the base world profile or to an integer from `1` through `1728000` for a custom full refill interval. Restart the world to apply it. Elapsed ticks settle at the previous rate. The mod backs up the prior ledger before a change. `/skysleylines profile` shows the base and effective values. For example, `72000` gives a three Minecraft day refill. Use `/serverconfig/` for a world-specific override and `config/` for the instance default. Exact raw fractions, whole currency balances, timestamps, the global account revision, and frozen world ratios are stored in `data/skysleylines.dat`. Untouched accounts are implicitly fully raw. Only full accounts with no converted funds can be pruned; fully converted accounts must remain saved. No per-node ticking or chunk loading is used. -Schema-1 worlds upgrade automatically. The loader validates first and copies the original data file to a unique `skysleylines-schema1-*.dat.bak` before any upgrade is saved. Raw balances and recharge remainders retain their exact value. Layout version 1 and the saved profile remain unchanged. Unknown versions, malformed fields, duplicate IDs, invalid ratios, and over-capacity accounts fail before Minecraft can substitute defaults. +Schema-1 worlds upgrade automatically. The loader validates first and copies the original data file to a unique `skysleylines-schema1-*.dat.bak` before any upgrade is saved. Unknown versions, malformed fields, duplicate IDs, invalid ratios, and balances above the storage limit fail before Minecraft can substitute defaults. -Worlds without a change to the effective recharge rate continue to save as schema 2. The first effective change switches to schema 3, which records the new interval and remains in use even after a reset to the base rate. Older builds refuse schema 3. A recharge change creates a `skysleylines-before-recharge-*.dat.bak` copy if the world already has a ledger. +A multiplier change uses schema 6 and keeps it after a reset to `1.0`. The first restart with `0.01` applies the reduction to existing worlds as well as new ones. To keep the original pool sizes in an existing world, set `1.0` before opening it. A multiplier change creates `skysleylines-before-capacity-*.dat.bak` when a ledger already exists. A recharge-only change creates `skysleylines-before-recharge-*.dat.bak`. Both changes settle elapsed recharge under the previous settings before saving. `/skysleylines profile` shows the effective multiplier and refill interval. The Ley Meter and `/skysleylines account` show the effective capacity at the nexus. Child mods register currencies and rate suppliers. A world remembers each currency's effective units-per-raw ratio. Removing a child preserves its balances and occupied capacity. Inactive currencies cannot receive new conversions or ordinary spending, but their value can explicitly return to raw through the API. Reinstalling the child restores access at the saved rate. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2f7ab4f..06e35d0 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -16,6 +16,14 @@ Run one Gradle process at a time against the shared cache. Import the inner `Sky Validation covers deterministic boundaries/order, concurrent samplers, bounded caches/searches, saved profiles, shared currency competition, exact conversions/returns, cancelled/reentrant events, frozen rates, value-preserving migrations, unloaded recovery, fractional recharge, NBT round trips/rejection, resources, exact packaged runtimes, and visual rods feedback. Runtime receipts are separate from user gameplay acceptance. Keep failed receipts and rerun only after fixing their cause or stating the changed validation conditions. +## Structure worldgen probe + +`worldgenProbeJar` builds the separate optional consumer. It registers a placement, a structure start and a saved marker piece. Use disposable worlds to check startup and exploration with Ley Lines present and absent. + +For a matched benchmark, launch once with `-Dley.structure.mode=export` for each profile or pin fixture and preserve the resulting `sites.tsv`. Start fresh copies with `-Dley.structure.bench=true` and point `-Dley.structure.oracle` at that file. Use `-Dley.structure.mode=control` for the precomputed placements and `-Dley.structure.mode=both` for live queries. Use `-Dley.structure.baseline=true` with the old JAR and control mode for historical column measurements. Compare the generated `structure-starts.tsv` before comparing the timings, allocated bytes and sampled monitor counts. Keep the packaged runtime, seed, fixture, exploration chunk set and Java settings fixed. + +Use `-Dley.structure.endToEndOnly=true` in both arms to time placement and generation without the synthetic column exercise and per-call counters inside the placement callback. Keep those diagnostic results alongside the full benchmark. + ## Packaged documentation Edit the public Markdown guides in `docs/` and link new pages from [the documentation index](README.md). Use relative links between bundled pages so readers can extract the directory and browse it offline. The ordinary JAR packages the guides under `META-INF/skysleylines/docs/` and advertises the index in its manifest. `verifyArtifacts` checks the packaged paths and bytes against the source guides. diff --git a/docs/MODPACKS.md b/docs/MODPACKS.md index 8595a56..4e8c48b 100644 --- a/docs/MODPACKS.md +++ b/docs/MODPACKS.md @@ -2,6 +2,8 @@ For NeoForge 1.21.1, ship `config/skysleylines-common.toml` for new-world layout, capacity, and base recharge. Ship `config/skysleylines-server.toml` for the default vision style and optional recharge override. A server operator can place a copy in `/serverconfig/` to set those two values for one world. The Ley Meter and `/skysleylines profile` show the applied settings. See [configuration and save compatibility](CONFIGURATION.md). +From 0.5.5 you can configure player pools and react to balance and job events. See the [player energy guide](PLAYER_ENERGY.md) for examples and policy files. + ## KubeJS The optional scripting bridge was introduced in Sky’s Ley Lines 0.5.1. Install the matching [KubeJS build 381](https://www.curseforge.com/minecraft/mc-mods/kubejs/files/9396059/additional-files) stack for Minecraft 1.21.1. Put these scripts on the server, or in the instance for single-player packs. diff --git a/docs/PLAYER_ENERGY.md b/docs/PLAYER_ENERGY.md new file mode 100644 index 0000000..9919882 --- /dev/null +++ b/docs/PLAYER_ENERGY.md @@ -0,0 +1,114 @@ +# Player energy pools + +Requires Sky’s Ley Lines 0.5.5 or later. The Java API is still version 2. + +A child mod decides which currencies each player can hold. A new player has an empty set of pools. A technomancer can have aether while a battle mage has mana, or a character can have both. Each pool holds whole converted units with its own capacity. Players cannot hold raw magic. Their units leave the nexus account when withdrawn and occupy no nexus capacity. Recharging a player draws from currency already converted at that nexus. + +## Java quick start + +Acquire the service on the server thread. Call this from your progression logic when a player earns their aether pool. Register the currency through the normal paradigm registry first. + +```java +var service = LeyLinesApi.playerPower(player.serverLevel().getServer()); +var aether = ResourceLocation.parse("example:aether"); +var source = new PowerRequest.Consumer( + ResourceLocation.parse("example:class_progression"), Optional.of(player.getUUID())); +var opened = service.configure(player.getUUID(), aether, + new PlayerPowerService.Settings(true, 1000, MagicAmount.fraction(3, 2)), source); +``` + +The last argument to `Settings` sets the optional automatic recharge speed. Use `null` to turn it off. This example attempts to draw an average of 1.5 aether per server tick while the player is at an eligible nexus with converted funds. It maintains one automatic job per pool and tries again at most once a second when supply or access is unavailable. Child mods can also register currency-level timing, range, and death defaults using `LeyLinesApi.registerPlayerEnergyDefaults(currency, policy)` during setup. + +| Method | What it does | +| --- | --- | +| `inspect(uuid)` | Reads an immutable map of configured pools with effective settings, active state, and units. Works for offline UUIDs. | +| `configure(uuid, currency, settings, source)` | Opens a pool or changes its requested settings. Disabling keeps its units. | +| `setBalance` / `adjust` | Trusted progression or admin changes. Negative adjustments can drain dormant pools. A positive adjustment needs an active pool with room. | +| `spend` | Deducts a positive whole number of units from an active pool. Use the successful receipt before applying gameplay effects. | +| `withdraw(player, currency, units, timing, source)` | Moves converted units from the nexus at the player’s position into the player pool. | +| `deposit(player, currency, units, timing, source)` | Moves converted units from the player into the nexus if it has room at the saved conversion rate. | +| `transfer(from, to, currency, units, timing, source)` | Moves converted units between online players. Both need the currency. | +| `convert(level, pos, currency, raw, timing, source)` | Converts exact raw magic at a nexus into that nexus’s currency account. | +| `job(id)` / `cancel(id)` | Reads or cancels a timed job. Cancellation leaves completed tranches in place. | + +Removing a currency’s mod, disabling a pool, or lowering its capacity retains the units. An over-capacity pool can spend its balance if it is active. It can receive units again once it has room. Nexus rate migrations continue to re-denominate nexus balances. Wallets and drops are already-issued currency units and retain their unit counts. + +## Timing, receipts, and protection + +Use `Timing.instant()` for one all-or-nothing move, `Timing.duration(40)` to spread the total across 40 server ticks, or `Timing.rate(MagicAmount.fraction(3, 2))` for an exact throughput. The rate is currency units per tick for transfers, and raw magic per tick for conversion. A fractional rate carries its remainder to the next tranche. A conversion debits only the raw amount represented by whole output units. If a timed request ends with a fractional output remainder, that raw stays in the nexus and the job’s `completed` reports the exact amount used. An instant conversion requires integral output. + +Starting a job reserves no funds or room. A timed job rechecks endpoints before each tranche. If funds or room run short, it moves what fits and ends with the reason. Nexus jobs are bound to the original nexus and require its chunk and the interaction position to remain loaded. A player can move within the eligible area. Moving away, changing dimension, disconnecting or dying ends the job. Transfers between players may start across dimensions by default, then stop if either changes dimension. An overloaded scheduler can take longer than the requested duration. Jobs end when the server stops. The last 1,024 terminal results are retained for the running server session. + +Listen to `PlayerPowerEvent.Pre` on the NeoForge game bus to cancel a proposed mutation. Its receipt includes the source, currency, amount, before/after pool values, optional nexus readings, and the interaction position when applicable. `PlayerPowerEvent.Post` follows a committed mutation. `JobFinished` reports the terminal reason and amount completed. Nexus withdrawals also run the existing nexus SPEND events with the actual player position. Conversions use the existing nexus CONVERT events. Read queries can run in callbacks. To perform another mutation in response, schedule it for a later server tick. + +## Pack and server policy + +The order is child code, data pack, instance file, then world file. A layer only replaces fields it specifies. Within a file its currency rules override its defaults. Rules tune pools that a child already configured. + +- A data pack can define aether in `data/example/player_energy/aether.json`. Its root is the single rule object. +- Instance policy goes in `config/skysleylines-player-energy.json`. +- World policy goes in `/serverconfig/skysleylines-player-energy.json`. Use the same file format as the instance policy. + +```json +{ + "defaults": { + "sameDimension": true, + "maxDistance": 32, + "honorKeepInventory": true + }, + "currencies": { + "example:aether": { + "capacity": "2500", + "autoRate": "3/2", + "transferRateLimit": "5", + "conversionRateLimit": "1/8", + "instant": false, + "death": "DROP", + "ownerOnly": true, + "dropLifetime": 6000 + } + } +} +``` + +`enabled`, `capacity`, and `autoRate` override the child’s pool settings. Set `autoRate` to JSON `null` to turn off automatic recharge. Rate limits are positive exact strings. `instant` controls the new player energy and conversion methods. A positive `maxDistance` is a three-dimensional block distance and requires both players to be in the same dimension. Zero removes the distance limit. A malformed policy stops startup. On `/reload`, an invalid policy is logged and the previous one remains in force. A successful reload that changes policy ends active jobs with `RELOADED`. + +## Death and saved worlds + +Default death policy is `DROP`. Active balances are emptied and leave collectible energy where the player died. Walk near the floating shards with a compatible pool to collect them. They move only as much as fits, and leftovers stay until collected or expired. By default any eligible player can pick them up, their lifetime is 6,000 server ticks, and `keepInventory` keeps the player’s balance. Other choices are `KEEP` and `LOSE`. `ownerOnly` restricts a drop to its owner. Dormant balances remain in the wallet in every mode. + +On the first persistent pool change, the mod backs up the current nexus ledger and upgrades `/data/skysleylines.dat` to schema 4. Player balances and drops live in that same file. Dropped entities are views of the saved balances and reappear when their chunks are loaded. Clients and servers must use matching builds. Back up the world before upgrading. Older builds cannot read schema 4. + +## KubeJS + +Define the currency through `StartupEvents.init` as described in the [pack maker guide](MODPACKS.md). This example assumes `kubejs:survey_aether` is already defined. + +```js +const surveyAether = 'kubejs:survey_aether' +const surveyPower = 'kubejs:survey_progression' + +PlayerEvents.loggedIn(event => { + const player = event.player + const pools = LeyLines.inspectPlayer(player).pools + if (!pools[surveyAether]) { + LeyLines.configurePool(player, surveyAether, true, '1000', '3/2', surveyPower) + } +}) + +ItemEvents.rightClicked('minecraft:spyglass', event => { + if (event.hand !== 'MAIN_HAND') return + const paid = LeyLines.spendPool(event.player, surveyAether, '10', surveyPower) + if (paid.succeeded) LeyLines.grantVision(event.player, surveyPower, 1200, 'skysleylines:crackle') +}) + +LeyLinesEvents.playerPoolEmpty(event => { + console.info(event.playerId + ' spent their last ' + event.currency) +}) +``` + +Use `inspectPlayer(player)` or `inspectPlayer(server, uuid)`, `configurePool(player, currency, enabled, capacity, autoRate, source)`, `setPoolBalance(player, currency, units, source)`, `adjustPool(player, currency, signedDelta, source)`, and `spendPool(player, currency, units, source)`. Use decimal strings for whole amounts and capacities, and exact strings such as `'3/2'` for rates. Pass `null` for no automatic recharge. + +For transfers use `withdrawToPlayer(player, currency, units, schedule, source)`, `depositFromPlayer(player, currency, units, schedule, source)`, and `transferPlayers(from, to, currency, units, schedule, source)`. For raw conversion use `scheduleConversion(level, x, y, z, currency, raw, schedule, source[, actor])`. Pass `'instant'`, `'duration:40'`, or `'rate:3/2'` for the schedule. Timed submissions return `status: 'RUNNING'` and a `jobId`. Read progress with `energyJob(server, jobId)` or cancel with `cancelEnergyJob(server, jobId)`. Balances, rates, and completed amounts are returned as exact strings. + +`LeyLinesEvents.playerPoolChanged` provides `playerId`, `currency`, `before`, `after`, `receipt`, and `server`. `playerPoolEmpty` has the same fields and fires only when positive units become zero. `energyJobFinished` provides `job` and `server`. These are committed notifications. Use a later server tick to initiate a new transaction from an event. + diff --git a/docs/README.md b/docs/README.md index 69f7e68..b1e4605 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,8 @@ Start with the guide for what you want to do. | Change layout defaults, recharge, or Ley Vision style | [Configuration and save compatibility](CONFIGURATION.md) | | Register pack currencies or write KubeJS scripts | [Pack maker guide](MODPACKS.md) | | Build a Java mod that samples the field or uses nexus power | [Java integration quick start](INTEGRATION.md) and [public API reference](API.md) | +| Place structures at nexuses, sample ore candidates, or stage an admin pin | [Worldgen and nexus pins](WORLDGEN_AND_PINS.md) | +| Set up personal pools, timed transfers, death drops, or KubeJS events | [Player energy pools](PLAYER_ENERGY.md) | | Diagnose a missing reading, payment failure, or startup problem | [Troubleshooting](TROUBLESHOOTING.md) | | Check version requirements and what changed | [Current release and supported languages](RELEASE-0.5.4.md), [0.5.3](RELEASE-0.5.3.md), [0.5.2](RELEASE-0.5.2.md), and [0.5.1](RELEASE-0.5.1.md) | diff --git a/docs/SERVER_GUIDE.md b/docs/SERVER_GUIDE.md index cd7c55d..8aaa80a 100644 --- a/docs/SERVER_GUIDE.md +++ b/docs/SERVER_GUIDE.md @@ -28,6 +28,10 @@ Permission levels below are Minecraft command permission levels. Brackets mark o The operator vision toggle ends on logout, respawn, or loss of permission. Timed effects from other mods can continue independently. Ley Meter use requires permission level 2. +## Add a named nexus + +See [Worldgen and nexus pins](WORLDGEN_AND_PINS.md#pin-a-nexus-in-an-existing-world) for preview, staging, retirement, and recovery commands. Locate shows authored Y for a pin and natural ground for a seeded nexus. + ## Balance a live world Use **Mods → Sky’s Ley Lines → Config** in a private single-player session or edit the server TOML while the world is closed. Change `rechargeTicksOverride` to retune recharge for an existing world. A value of `72000` is three Minecraft days for a full refill. Reopen the world and check `effectiveRechargeTicks` with the profile command. diff --git a/docs/WORLDGEN_AND_PINS.md b/docs/WORLDGEN_AND_PINS.md new file mode 100644 index 0000000..9edd92a --- /dev/null +++ b/docs/WORLDGEN_AND_PINS.md @@ -0,0 +1,55 @@ +# Structure generation and nexus pins + +Requires Sky’s Ley Lines 0.5.5 for NeoForge 1.21.1. A mod can use the same nexus map for structures, ore placement, and gameplay. Operators can add named nexuses to an existing world. + +## Place a structure at a nexus + +A structure placement or structure-start callback can use its `RandomState` on the generation worker. + +```java +var field = LeyLinesApi.worldgenSampler(context.randomState()); +if (field.isEmpty()) return Optional.empty(); +var chunk = context.chunkPos(); +var sites = field.get().nexusesInChunk(chunk.x, chunk.z); +``` + +The result contains every nexus centred inside that chunk, sorted by stable ID. A chunk owns the half-open range from its minimum block coordinate through the next chunk boundary. This also works at negative coordinates. Queries are immutable, use bounded geometry lookups, and never sample terrain or load a chunk. Seeded nexuses supply exact X/Z and strength. Use your structure’s terrain rules to choose Y. Authored pins also supply `authoredY()` if you want to follow their explicit elevation. + +Use each nexus’s stable ID with your own salt to select the sites that should get a structure. Check the same selection in both placement and the structure-start callback. A chunk can contain multiple sites. If the structure needs one start per chunk, choose one in stable order or attach several pieces to one start. The normal biome and structure checks still apply. + +The optional lookup uses the exact generator state from the callback. An unbound state returns empty. To support play without Ley Lines, declare an optional dependency, check `ModList.get().isLoaded("skysleylines")`, and keep direct API references in a class loaded only when the mod is present. Keep your ordinary placement rule for that case. + +## Sample an ore candidate + +For a placed feature, acquire `LeyLinesApi.forLevel(level).field()` during the server’s `LevelEvent.Load` and publish that sampler to workers. Remove it on level unload. Structure code can use the state lookup above. Sample a candidate column once and reuse its strength across heights. + +```java +var column = sampler.sampleColumn(x, z); +double strength = column.naturalStrengthAt(y); +``` + +A cold column can compute the natural base height. Query the columns you intend to use and reuse the result where a placement checks several Y values. The account and transaction API requires the server thread. + +## Pin a nexus in an existing world + +The following commands require permission level 4. Place a pin in the Overworld with a unique namespaced ID and strength greater than zero through one. The position must be within build bounds over natural terrain. The Y coordinate is explicit, so a pin can sit in a cave. + +```text +/skysleylines nexus pin preview pack:dragon_roost 1200 42 -480 0.85 +/skysleylines nexus pin add pack:dragon_roost 1200 42 -480 0.85 +/skysleylines nexus pin list +/skysleylines nexus pin remove pack:dragon_roost +``` + +Preview reports the capacity, nearby nexus fields that can overlap, and up to two joins to seeded ley lines within 512 blocks. The joins slope from the pin to the natural ground at the seeded line. A pin can be isolated. The world supports up to 256 active or staged pins. Two pins may share a column at different heights. + +Adds and removals take effect on the next world start. After restart, the same pins appear in Ley Vision, dowsing, the Ley Meter, and worldgen queries. Generated caves and ores stay where they are. New chunks can use the active pins as sites. Natural nexus IDs stay stable. In overlapping fields, the nearest eligible anchor controls the account, with a pin taking priority at its exact anchor. + +The first pin write backs up the ledger. Pin data uses schema 5 or later. Retirement removes the pin from the field and keeps its saved converted balances. Its ID stays reserved. Use the following command to move whole units to an active nexus with room. The target can receive retained currency even when its mod is absent. + +```text +/skysleylines nexus pin recover pack:dragon_roost example:aether 500 1166 64 -749 +``` + +Recovery uses the saved rate to check target capacity. The debit and credit commit together. A rate migration that would revalue currency in a retired account waits until those units are recovered. Java mods can observe or cancel the proposed move through `NexusRecoveryEvent.Pre` and observe a committed move through `NexusRecoveryEvent.Post`. Both events include the source and destination accounts and positions. + diff --git a/gradle.properties b/gradle.properties index 20ee0b6..c0c21c2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,7 +7,7 @@ minecraft_version=1.21.1 neo_version=21.1.247 mod_id=skysleylines mod_name=Sky's Ley Lines -mod_version=0.5.4.121012 +mod_version=0.5.5.121012 kubejs_version=2101.7.2-build.381 mod_group_id=zone.moddev.mc.skysleylines mod_license=LGPL-2.1 diff --git a/src/main/java/zone/moddev/mc/skysleylines/LeyConfig.java b/src/main/java/zone/moddev/mc/skysleylines/LeyConfig.java index 8fcd7c2..2c4ded1 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/LeyConfig.java +++ b/src/main/java/zone/moddev/mc/skysleylines/LeyConfig.java @@ -1,8 +1,10 @@ package zone.moddev.mc.skysleylines; +import java.math.BigDecimal; import java.util.List; import net.neoforged.neoforge.common.ModConfigSpec; import zone.moddev.mc.skysleylines.api.LeyProfile; +import zone.moddev.mc.skysleylines.core.NexusLedger; /** Installation defaults copied once when each world first initializes its ley profile. */ public final class LeyConfig { @@ -25,6 +27,7 @@ public final class LeyConfig { public static final ModConfigSpec SPEC = BUILDER.build(); private static final ModConfigSpec.Builder SERVER = new ModConfigSpec.Builder(); public static final String RECHARGE_OVERRIDE_KEY = "rechargeTicksOverride"; + public static final String CAPACITY_MULTIPLIER_KEY = "nexusCapacityMultiplier"; public static final String VISION_STYLE_KEY = "defaultVisionStyle"; public static final List VISION_STYLES = List.of("river", "crackle", "sparkles", "glow"); private static final ModConfigSpec.ConfigValue RECHARGE_OVERRIDE = SERVER @@ -33,6 +36,13 @@ public final class LeyConfig { .worldRestart() .translation("config.skysleylines.rechargeTicksOverride") .define(RECHARGE_OVERRIDE_KEY, (Object) 0, value -> value != null); + private static final ModConfigSpec.ConfigValue CAPACITY_MULTIPLIER = SERVER + .comment("Multiplies the base raw capacity of natural and authored nexuses on world start.", + "0.01 gives 1% of the original capacity; 1.0 uses the original capacity.", + "Use 0.0001 to 100 with at most four decimal places. Existing converted units are preserved.") + .worldRestart() + .translation("config.skysleylines.nexusCapacityMultiplier") + .define(CAPACITY_MULTIPLIER_KEY, (Object) 0.01, value -> value != null); public static final ModConfigSpec.ConfigValue DEFAULT_VISION_STYLE = SERVER .comment("Ley Vision style for operator vision and unstyled grants. Applies to existing worlds too.", "Allowed values: river, crackle, sparkles, glow") @@ -54,4 +64,28 @@ public static int rechargeOverride() { public static boolean validRechargeOverride(int ticks) { return ticks >= 0 && ticks <= 1_728_000; } + + public static double configuredCapacityMultiplier() { + return CAPACITY_MULTIPLIER.getRaw() instanceof Number value ? value.doubleValue() : Double.NaN; + } + + public static int capacityScale() { + double multiplier = configuredCapacityMultiplier(); + if (validCapacityMultiplier(multiplier)) return capacityScale(multiplier); + throw new IllegalArgumentException("nexusCapacityMultiplier must be a number from 0.0001 through 100 with at most four decimal places"); + } + + public static boolean validCapacityMultiplier(double multiplier) { + if (!Double.isFinite(multiplier) || multiplier < 0.0001 || multiplier > 100) return false; + try { + capacityScale(multiplier); + return true; + } catch (ArithmeticException invalidPrecision) { + return false; + } + } + + private static int capacityScale(double multiplier) { + return BigDecimal.valueOf(multiplier).multiply(BigDecimal.valueOf(NexusLedger.CAPACITY_SCALE_ONE)).intValueExact(); + } } diff --git a/src/main/java/zone/moddev/mc/skysleylines/SkysLeyLines.java b/src/main/java/zone/moddev/mc/skysleylines/SkysLeyLines.java index 539990b..f2d2539 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/SkysLeyLines.java +++ b/src/main/java/zone/moddev/mc/skysleylines/SkysLeyLines.java @@ -1,7 +1,16 @@ package zone.moddev.mc.skysleylines; import zone.moddev.mc.skysleylines.integration.kubejs.PackCurrencies; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MobCategory; import net.minecraft.world.item.CreativeModeTabs; +import net.neoforged.neoforge.event.OnDatapackSyncEvent; +import net.neoforged.neoforge.event.entity.living.LivingDeathEvent; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; +import zone.moddev.mc.skysleylines.server.EnergyOrb; +import zone.moddev.mc.skysleylines.server.PlayerEnergyEvents; import net.minecraft.world.item.Item; import net.neoforged.bus.api.EventPriority; import net.neoforged.bus.api.IEventBus; @@ -15,6 +24,8 @@ import net.neoforged.neoforge.event.server.ServerStartingEvent; import net.neoforged.neoforge.event.server.ServerStoppedEvent; import net.neoforged.neoforge.registries.DeferredItem; +import net.neoforged.neoforge.registries.DeferredHolder; +import zone.moddev.mc.skysleylines.core.PlayerEnergyDefaults; import net.neoforged.neoforge.registries.DeferredRegister; import zone.moddev.mc.skysleylines.api.LeyLinesApi; import zone.moddev.mc.skysleylines.item.DowsingRods; @@ -42,7 +53,13 @@ public final class SkysLeyLines { public static final DeferredItem LEY_METER = ITEMS.register("ley_meter", () -> new LeyMeter(new Item.Properties().stacksTo(1))); + private static final DeferredRegister> ENTITIES = DeferredRegister.create(Registries.ENTITY_TYPE, MOD_ID); + public static final DeferredHolder, EntityType> ENERGY_ORB = + ENTITIES.register("energy_orb", () -> EntityType.Builder.of(EnergyOrb::new, MobCategory.MISC) + .sized(0.45F, 0.5F).clientTrackingRange(4).noSummon().noSave().build(MOD_ID + ":energy_orb")); + public SkysLeyLines(IEventBus bus, ModContainer container) { + ENTITIES.register(bus); ITEMS.register(bus); container.registerConfig(ModConfig.Type.COMMON, LeyConfig.SPEC); container.registerConfig(ModConfig.Type.SERVER, LeyConfig.SERVER_SPEC); @@ -50,17 +67,26 @@ public SkysLeyLines(IEventBus bus, ModContainer container) { bus.addListener((FMLLoadCompleteEvent event) -> event.enqueueWork(() -> { PackCurrencies.completeRegistration(); LeyLinesApi.paradigms().freeze(); + PlayerEnergyDefaults.freeze(); VisionUpdates.freezeStyles(); })); NeoForge.EVENT_BUS.addListener(LeyCommands::register); NeoForge.EVENT_BUS.addListener(EventPriority.LOWEST, LeyMeter::onBlockUse); + NeoForge.EVENT_BUS.addListener(LeyServerContext::levelLoad); + NeoForge.EVENT_BUS.addListener(LeyServerContext::levelUnload); NeoForge.EVENT_BUS.addListener(SkysLeyLines::serverStarting); NeoForge.EVENT_BUS.addListener(SkysLeyLines::serverStopped); NeoForge.EVENT_BUS.addListener(DowsingUpdates::tick); NeoForge.EVENT_BUS.addListener(VisionUpdates::tick); NeoForge.EVENT_BUS.addListener(VisionUpdates::logout); NeoForge.EVENT_BUS.addListener(VisionUpdates::respawn); - bus.addListener((RegisterPayloadHandlersEvent event) -> event.registrar("2").playToClient( + NeoForge.EVENT_BUS.addListener((ServerTickEvent.Post event) -> PlayerEnergyEvents.tick(event)); + NeoForge.EVENT_BUS.addListener((LivingDeathEvent event) -> PlayerEnergyEvents.death(event)); + NeoForge.EVENT_BUS.addListener((PlayerEvent.Clone event) -> PlayerEnergyEvents.clone(event)); + NeoForge.EVENT_BUS.addListener((PlayerEvent.PlayerLoggedOutEvent event) -> PlayerEnergyEvents.logout(event)); + NeoForge.EVENT_BUS.addListener((PlayerEvent.PlayerChangedDimensionEvent event) -> PlayerEnergyEvents.dimension(event)); + NeoForge.EVENT_BUS.addListener((OnDatapackSyncEvent event) -> PlayerEnergyEvents.reload(event)); + bus.addListener((RegisterPayloadHandlersEvent event) -> event.registrar("4").playToClient( DowsingPayload.TYPE, DowsingPayload.CODEC, (payload, context) -> DowsingSignals.receive(payload, context.player().level().getGameTime())) .playToClient(VisionPayload.TYPE, VisionPayload.CODEC, @@ -75,6 +101,8 @@ private static void creativeItems(BuildCreativeModeTabContentsEvent event) { if (event.getTabKey() == CreativeModeTabs.TOOLS_AND_UTILITIES) event.accept(DOWSING_RODS); if (event.getTabKey() == CreativeModeTabs.OP_BLOCKS && event.hasPermissions()) event.accept(LEY_METER); } - private static void serverStarting(ServerStartingEvent event) { LeyLinesApi.forLevel(event.getServer().overworld()); } + private static void serverStarting(ServerStartingEvent event) { + LeyServerContext.playerEnergy(event.getServer()); + } private static void serverStopped(ServerStoppedEvent event) { LeyServerContext.clear(event.getServer()); } } diff --git a/src/main/java/zone/moddev/mc/skysleylines/api/LeyColumn.java b/src/main/java/zone/moddev/mc/skysleylines/api/LeyColumn.java index 15524f7..1027cf0 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/api/LeyColumn.java +++ b/src/main/java/zone/moddev/mc/skysleylines/api/LeyColumn.java @@ -1,19 +1,126 @@ package zone.moddev.mc.skysleylines.api; +import java.util.List; import java.util.Optional; /** Reuse a column in generation loops. The primitive strength method allocates nothing and reads no terrain. */ public record LeyColumn(boolean active, int naturalSurfaceY, int verticalReach, double lineStrength, - double nexusStrength, Optional nexus, double distanceToLine, double distanceToNexus) { + double nexusStrength, Optional nexus, double distanceToLine, double distanceToNexus, + List authored) { + /** A projected connector (null nexus) or pin at this column. */ + public record Influence(double anchorY, double strength, double distance, double horizontalReach, Nexus nexus) {} + + public LeyColumn(boolean active, int naturalSurfaceY, int verticalReach, double lineStrength, + double nexusStrength, Optional nexus, double distanceToLine, double distanceToNexus) { + this(active, naturalSurfaceY, verticalReach, lineStrength, nexusStrength, nexus, distanceToLine, distanceToNexus, List.of()); + } + + public LeyColumn { + authored = List.copyOf(authored); + } + + private double factor(double anchor, int y) { + return Math.max(0, 1.0 - Math.abs(y - anchor) / verticalReach); + } + + @Override public double lineStrength() { + double result = lineStrength; + for (int i = 0; i < authored.size(); i++) { + var entry = authored.get(i); + if (entry.nexus == null) result = Math.max(result, entry.strength); + } + return result; + } + + @Override public double nexusStrength() { + double result = nexusStrength; + for (int i = 0; i < authored.size(); i++) { + var entry = authored.get(i); + if (entry.nexus != null) result = Math.max(result, entry.strength); + } + return result; + } + + @Override public Optional nexus() { + Nexus result = nexus.orElse(null); + double best = distanceToNexus; + for (int i = 0; i < authored.size(); i++) { + var entry = authored.get(i); + if (entry.nexus != null && entry.distance < best) { + result = entry.nexus; + best = entry.distance; + } + } + return Optional.ofNullable(result); + } + + @Override public double distanceToLine() { return planarDistance(distanceToLine, false); } + @Override public double distanceToNexus() { return planarDistance(distanceToNexus, true); } + + private double planarDistance(double distance, boolean toNexus) { + for (int i = 0; i < authored.size(); i++) { + var entry = authored.get(i); + if ((entry.nexus != null) == toNexus) distance = Math.min(distance, entry.distance); + } + return distance; + } + public double verticalFactorAt(int y) { - return active ? Math.max(0, 1.0 - Math.abs((double) y - naturalSurfaceY) / verticalReach) : 0; + if (!active) return 0; + double result = factor(naturalSurfaceY, y); + for (int i = 0; i < authored.size(); i++) result = Math.max(result, factor(authored.get(i).anchorY, y)); + return result; } - public double naturalStrengthAt(int y) { return Math.max(lineStrength, nexusStrength) * verticalFactorAt(y); } + + public double naturalStrengthAt(int y) { + if (!active) return 0; + double result = Math.max(lineStrength, nexusStrength) * factor(naturalSurfaceY, y); + for (int i = 0; i < authored.size(); i++) { + var entry = authored.get(i); + result = Math.max(result, entry.strength * factor(entry.anchorY, y)); + } + return result; + } + public LeySample atHeight(int y) { - double vertical = verticalFactorAt(y); - return new LeySample(active, naturalStrengthAt(y), lineStrength * vertical, nexusStrength * vertical, - vertical, nexus, distanceToLine, distanceToNexus); + double vertical = active ? factor(naturalSurfaceY, y) : 0; + double line = lineStrength * vertical, node = nexusStrength * vertical; + if (authored.isEmpty()) return new LeySample(active, Math.max(line, node), line, node, vertical, nexus, distanceToLine, distanceToNexus); + Nexus selected = vertical > 0 ? nexus.orElse(null) : null; + double selectedVertical = vertical, selectedDistance = distanceToNexus; + double score = Double.POSITIVE_INFINITY; + double bestVertical = vertical; + for (int i = 0; i < authored.size(); i++) { + var entry = authored.get(i); + double local = factor(entry.anchorY, y); + bestVertical = Math.max(bestVertical, local); + if (entry.nexus == null) { + line = Math.max(line, entry.strength * local); + continue; + } + node = Math.max(node, entry.strength * local); + if (local <= 0) continue; + if (score == Double.POSITIVE_INFINITY && selected != null) + score = score(selectedDistance, entry.horizontalReach, selectedVertical); + double rank = score(entry.distance, entry.horizontalReach, local); + if (rank < score || (rank == score && (selected == null + || (rank == 0 && selected.id().pinId().isEmpty()) || entry.nexus.id().compareTo(selected.id()) < 0))) { + score = rank; + selected = entry.nexus; + selectedVertical = local; + selectedDistance = entry.distance; + } + } + return new LeySample(active, Math.max(line, node), line, node, selected == null ? bestVertical : selectedVertical, + Optional.ofNullable(selected), distanceToLine(), selected == null ? distanceToNexus() : selectedDistance); } + + private static double score(double distance, double reach, double vertical) { + double horizontal = distance / reach; + double height = 1 - vertical; + return horizontal * horizontal + height * height; + } + public static LeyColumn inactive() { return new LeyColumn(false, 0, 1, 0, 0, Optional.empty(), Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY); } diff --git a/src/main/java/zone/moddev/mc/skysleylines/api/LeyFieldSampler.java b/src/main/java/zone/moddev/mc/skysleylines/api/LeyFieldSampler.java index 93c5998..75213b6 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/api/LeyFieldSampler.java +++ b/src/main/java/zone/moddev/mc/skysleylines/api/LeyFieldSampler.java @@ -4,6 +4,11 @@ /** Thread-safe, read-only field queries; never loads chunks, mutates reserves, or exposes the world seed. */ public interface LeyFieldSampler { + /** All centres owned by this chunk, in stable ID order. No terrain work is required. */ + default java.util.List nexusesInChunk(int chunkX, int chunkZ) { + throw new UnsupportedOperationException("This sampler does not support chunk enumeration"); + } + LeyColumn sampleColumn(int x, int z); default LeySample sample(int x, int y, int z) { return sampleColumn(x, z).atHeight(y); } /** Radius must be positive and at most min(4096, 2 * saved spacing). */ diff --git a/src/main/java/zone/moddev/mc/skysleylines/api/LeyLinesApi.java b/src/main/java/zone/moddev/mc/skysleylines/api/LeyLinesApi.java index fa600a2..8902765 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/api/LeyLinesApi.java +++ b/src/main/java/zone/moddev/mc/skysleylines/api/LeyLinesApi.java @@ -1,8 +1,11 @@ package zone.moddev.mc.skysleylines.api; import java.util.Map; +import java.util.Optional; +import net.minecraft.world.level.levelgen.RandomState; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.MinecraftServer; import zone.moddev.mc.skysleylines.server.LeyServerContext; /** Entry point for consumer mods. Acquire on the server thread; the returned sampler may be used by worldgen workers. */ @@ -17,6 +20,17 @@ public record Context(LeyProfile profile, LeyFieldSampler field, NexusPowerServi public static Context forLevel(ServerLevel level) { return LeyServerContext.forLevel(level); } + /** Worker-safe lookup using the exact RandomState supplied by a structure callback. */ + public static Optional worldgenSampler(RandomState state) { return LeyServerContext.worldgenSampler(state); } + + /** Register currency-level defaults during mod setup. Duplicate definitions are rejected. */ + public static void registerPlayerEnergyDefaults(ResourceLocation currency, PlayerPowerService.CurrencyPolicy defaults) { + zone.moddev.mc.skysleylines.core.PlayerEnergyDefaults.register(currency, defaults); + } + + /** Converted player pools, transfers, and scheduled conversions for the current server. */ + public static PlayerPowerService playerPower(MinecraftServer server) { return LeyServerContext.playerEnergy(server); } + /** Saved currencies, including inactive entries. Read on the server thread. */ public static Map currencyInfo(ServerLevel level) { return LeyServerContext.currencyInfo(level); diff --git a/src/main/java/zone/moddev/mc/skysleylines/api/Nexus.java b/src/main/java/zone/moddev/mc/skysleylines/api/Nexus.java index e4e7eda..5fca942 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/api/Nexus.java +++ b/src/main/java/zone/moddev/mc/skysleylines/api/Nexus.java @@ -1,13 +1,25 @@ package zone.moddev.mc.skysleylines.api; -/** Seed-derived planar nexus. Its terrain height is resolved separately. */ -public record Nexus(NexusId id, double x, double z, double strength) { +/** A planar intersection or a named nexus with an authored elevation. */ +public record Nexus(NexusId id, double x, double z, double strength, java.util.OptionalInt authoredY) { + public Nexus(NexusId id, double x, double z, double strength) { + this(id, x, z, strength, java.util.OptionalInt.empty()); + } + public Nexus { - if (id == null || !Double.isFinite(x) || !Double.isFinite(z) + if (id == null || authoredY == null || id.pinId().isPresent() != authoredY.isPresent() || !Double.isFinite(x) || !Double.isFinite(z) || !Double.isFinite(strength) || strength <= 0 || strength > 1) { throw new IllegalArgumentException("Invalid nexus"); } } + @Override public int hashCode() { + int hash = id.hashCode(); + hash = 31 * hash + Double.hashCode(x); + hash = 31 * hash + Double.hashCode(z); + hash = 31 * hash + Double.hashCode(strength); + return authoredY.isPresent() ? 31 * hash + authoredY.hashCode() : hash; + } + public double distanceSquared(double px, double pz) { double dx = x - px, dz = z - pz; return dx * dx + dz * dz; diff --git a/src/main/java/zone/moddev/mc/skysleylines/api/NexusId.java b/src/main/java/zone/moddev/mc/skysleylines/api/NexusId.java index 88b1697..29e4979 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/api/NexusId.java +++ b/src/main/java/zone/moddev/mc/skysleylines/api/NexusId.java @@ -1,21 +1,54 @@ package zone.moddev.mc.skysleylines.api; import java.util.Objects; +import java.util.Optional; +import net.minecraft.resources.ResourceLocation; + +/** Stable identity of a seeded intersection or a named pin. */ +public record NexusId(String dimension, int ax, int az, int bx, int bz, + Optional pinId) implements Comparable { + public NexusId(String dimension, int ax, int az, int bx, int bz) { + this(dimension, ax, az, bx, bz, Optional.empty()); + } + + public static NexusId pinned(String dimension, ResourceLocation name) { + return new NexusId(dimension, 0, 0, 0, 0, Optional.of(name)); + } -/** Collision-free identity of an intersection: dimension and an ordered pair of source cells. */ -public record NexusId(String dimension, int ax, int az, int bx, int bz) implements Comparable { public NexusId { Objects.requireNonNull(dimension); + Objects.requireNonNull(pinId); if (!dimension.matches("[a-z0-9_.-]+:[a-z0-9/._-]+")) throw new IllegalArgumentException("Invalid dimension"); - if (ax > bx || (ax == bx && az >= bz)) throw new IllegalArgumentException("Line identities must be distinct and ordered"); + if (pinId.isPresent()) { + if (ax != 0 || az != 0 || bx != 0 || bz != 0) throw new IllegalArgumentException("Pin has source cells"); + } else if (ax > bx || (ax == bx && az >= bz)) { + throw new IllegalArgumentException("Line identities must be distinct and ordered"); + } } @Override public int compareTo(NexusId other) { int result = dimension.compareTo(other.dimension); - if (result == 0) result = Integer.compare(ax, other.ax); + if (result != 0) return result; + if (pinId.isPresent()) return other.pinId.isPresent() ? pinId.get().compareTo(other.pinId.get()) : 1; + if (other.pinId.isPresent()) return -1; + result = Integer.compare(ax, other.ax); if (result == 0) result = Integer.compare(az, other.az); if (result == 0) result = Integer.compare(bx, other.bx); if (result == 0) result = Integer.compare(bz, other.bz); return result; } + + @Override public int hashCode() { + if (pinId.isPresent()) return Objects.hash(dimension, pinId); + int hash = dimension.hashCode(); + hash = 31 * hash + ax; + hash = 31 * hash + az; + hash = 31 * hash + bx; + return 31 * hash + bz; + } + + @Override public String toString() { + if (pinId.isPresent()) return "NexusId[dimension=" + dimension + ", pin=" + pinId.get() + "]"; + return "NexusId[dimension=" + dimension + ", ax=" + ax + ", az=" + az + ", bx=" + bx + ", bz=" + bz + "]"; + } } diff --git a/src/main/java/zone/moddev/mc/skysleylines/api/NexusRecoveryEvent.java b/src/main/java/zone/moddev/mc/skysleylines/api/NexusRecoveryEvent.java new file mode 100644 index 0000000..197c7a5 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/api/NexusRecoveryEvent.java @@ -0,0 +1,33 @@ +package zone.moddev.mc.skysleylines.api; + +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.neoforged.bus.api.Event; +import net.neoforged.bus.api.ICancellableEvent; + +/** Protection hooks for an atomic transfer from a retired pin to an active nexus. */ +public abstract class NexusRecoveryEvent extends Event { + public record Account(NexusId id, BlockPos position, AccountSnapshot before, AccountSnapshot after) {} + public record Receipt(long revision, ResourceLocation currency, long units, PowerRequest.Consumer consumer, + Account source, Account destination) {} + + private final ServerLevel level; + private final Receipt receipt; + + protected NexusRecoveryEvent(ServerLevel level, Receipt receipt) { + this.level = level; + this.receipt = receipt; + } + + public ServerLevel level() { return level; } + public Receipt receipt() { return receipt; } + + public static final class Pre extends NexusRecoveryEvent implements ICancellableEvent { + public Pre(ServerLevel level, Receipt receipt) { super(level, receipt); } + } + + public static final class Post extends NexusRecoveryEvent { + public Post(ServerLevel level, Receipt receipt) { super(level, receipt); } + } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/api/PlayerPowerEvent.java b/src/main/java/zone/moddev/mc/skysleylines/api/PlayerPowerEvent.java new file mode 100644 index 0000000..6ac9f79 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/api/PlayerPowerEvent.java @@ -0,0 +1,36 @@ +package zone.moddev.mc.skysleylines.api; + +import net.minecraft.server.MinecraftServer; +import net.neoforged.bus.api.Event; +import net.neoforged.bus.api.ICancellableEvent; + +/** Server-thread checks and notifications. Inspect freely in a listener. Nested mutations are rejected. */ +public abstract class PlayerPowerEvent extends Event { + private final MinecraftServer server; + private final PlayerPowerService.Receipt receipt; + + protected PlayerPowerEvent(MinecraftServer server, PlayerPowerService.Receipt receipt) { + this.server = server; + this.receipt = receipt; + } + public MinecraftServer server() { return server; } + public PlayerPowerService.Receipt receipt() { return receipt; } + + public static final class Pre extends PlayerPowerEvent implements ICancellableEvent { + public Pre(MinecraftServer server, PlayerPowerService.Receipt receipt) { super(server, receipt); } + } + public static final class Post extends PlayerPowerEvent { + public Post(MinecraftServer server, PlayerPowerService.Receipt receipt) { super(server, receipt); } + } + public static final class JobFinished extends Event { + private final MinecraftServer server; + private final PlayerPowerService.Job job; + + public JobFinished(MinecraftServer server, PlayerPowerService.Job job) { + this.server = server; + this.job = job; + } + public MinecraftServer server() { return server; } + public PlayerPowerService.Job job() { return job; } + } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/api/PlayerPowerService.java b/src/main/java/zone/moddev/mc/skysleylines/api/PlayerPowerService.java new file mode 100644 index 0000000..756ac67 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/api/PlayerPowerService.java @@ -0,0 +1,90 @@ +package zone.moddev.mc.skysleylines.api; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.core.GlobalPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +/** World-wide converted power pools. All methods require the owning server thread. */ +public interface PlayerPowerService { + enum Operation { WITHDRAW, DEPOSIT, TRANSFER, CONVERT, CONFIGURE, ADJUST, SPEND, DEATH, PICKUP } + enum Status { + SUCCESS, RUNNING, CANCELLED, CHECK_FAILED, REENTRANT, UNKNOWN_CURRENCY, DORMANT, + INVALID_AMOUNT, NO_NEXUS, INSUFFICIENT_POWER, CAPACITY, OUT_OF_RANGE, UNAVAILABLE, + POLICY, OVERFLOW, STORAGE_FAILED, EXPIRED, MOVED, DISCONNECTED, DIED, RELOADED + } + enum DeathMode { DROP, KEEP, LOSE } + + /** Code defaults for a currency. Pack and server policy can override individual fields. */ + record CurrencyPolicy(boolean instant, MagicAmount transferRateLimit, MagicAmount conversionRateLimit, + boolean sameDimension, int maxDistance, DeathMode death, boolean honorKeepInventory, + boolean ownerOnly, int dropLifetime) { + public static final CurrencyPolicy DEFAULT = new CurrencyPolicy(true, null, null, false, 0, + DeathMode.DROP, true, false, 6000); + + public CurrencyPolicy { + java.util.Objects.requireNonNull(death); + if ((transferRateLimit != null && transferRateLimit.isZero()) + || (conversionRateLimit != null && conversionRateLimit.isZero()) + || maxDistance < 0 || maxDistance > 60_000_000 || dropLifetime < 1 || dropLifetime > 1_728_000) + throw new IllegalArgumentException("Invalid currency policy"); + } + } + + /** Positive whole-unit capacity. Disabling retains the balance. A null auto rate disables built-in recharge. */ + record Settings(boolean enabled, long capacity, MagicAmount autoRate) { + public Settings { + if (capacity < 0 || (autoRate != null && autoRate.isZero())) throw new IllegalArgumentException("Invalid pool settings"); + } + } + record Pool(long balance, Settings settings, boolean active) {} + record Snapshot(UUID player, Map pools) { + public Snapshot { pools = Map.copyOf(pools); } + } + record Change(UUID player, Pool before, Pool after) {} + record Receipt(Operation operation, ResourceLocation currency, MagicAmount amount, PowerRequest.Consumer consumer, + Optional nexus, Optional drop, Map changes, + Optional nexusBefore, Optional nexusAfter, Optional anchor) { + public Receipt { changes = Map.copyOf(changes); } + } + record Result(Status status, Optional receipt) { + public boolean succeeded() { return status == Status.SUCCESS; } + public static Result of(Status status) { return new Result(status, Optional.empty()); } + public static Result committed(Receipt receipt) { return new Result(Status.SUCCESS, Optional.of(receipt)); } + } + + /** Select exactly one schedule. The rate uses the same units as the request amount. */ + record Timing(int ticks, MagicAmount perTick) { + public Timing { + if (ticks < 0 || (perTick != null && (ticks != 0 || perTick.isZero()))) throw new IllegalArgumentException("Invalid energy timing"); + } + public static Timing instant() { return new Timing(0, null); } + public static Timing duration(int ticks) { + if (ticks <= 0) throw new IllegalArgumentException("Duration must be positive"); + return new Timing(ticks, null); + } + public static Timing rate(MagicAmount amount) { return new Timing(0, java.util.Objects.requireNonNull(amount)); } + public boolean isInstant() { return ticks == 0 && perTick == null; } + } + record Job(UUID id, Operation operation, ResourceLocation currency, MagicAmount total, + MagicAmount completed, Status status, PowerRequest.Consumer consumer) {} + record Submission(Status status, Optional jobId, Optional receipt) {} + + Snapshot inspect(UUID player); + Result configure(UUID player, ResourceLocation currency, Settings settings, PowerRequest.Consumer consumer); + /** Trusted adjustment. May reduce a dormant or over-capacity balance. */ + Result setBalance(UUID player, ResourceLocation currency, long units, PowerRequest.Consumer consumer); + Result adjust(UUID player, ResourceLocation currency, long delta, PowerRequest.Consumer consumer); + Result spend(UUID player, ResourceLocation currency, long units, PowerRequest.Consumer consumer); + + Submission withdraw(ServerPlayer player, ResourceLocation currency, long units, Timing timing, PowerRequest.Consumer consumer); + Submission deposit(ServerPlayer player, ResourceLocation currency, long units, Timing timing, PowerRequest.Consumer consumer); + Submission transfer(ServerPlayer from, ServerPlayer to, ResourceLocation currency, long units, Timing timing, PowerRequest.Consumer consumer); + Submission convert(ServerLevel level, BlockPos pos, ResourceLocation currency, MagicAmount raw, Timing timing, PowerRequest.Consumer consumer); + Optional job(UUID id); + boolean cancel(UUID id); +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/client/DowsingClient.java b/src/main/java/zone/moddev/mc/skysleylines/client/DowsingClient.java index de15bb9..be2fa23 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/client/DowsingClient.java +++ b/src/main/java/zone/moddev/mc/skysleylines/client/DowsingClient.java @@ -52,6 +52,8 @@ public DowsingClient(IEventBus bus, ModContainer container) { (mod, parent) -> new ConfigurationScreen(mod, parent, LeyConfigSection::new)); bus.addListener((ModelEvent.RegisterAdditional event) -> { event.register(ROD); event.register(ICON); }); bus.addListener(DowsingClient::extensions); + bus.addListener((EntityRenderersEvent.RegisterRenderers event) -> + event.registerEntityRenderer(SkysLeyLines.ENERGY_ORB.get(), EnergyOrbRenderer::new)); bus.addListener((EntityRenderersEvent.AddLayers event) -> { wideHands = slimHands = null; // Also invalidated on resource/model reload. for (var skin : event.getSkins()) { diff --git a/src/main/java/zone/moddev/mc/skysleylines/client/EnergyOrbRenderer.java b/src/main/java/zone/moddev/mc/skysleylines/client/EnergyOrbRenderer.java new file mode 100644 index 0000000..4dd82a6 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/client/EnergyOrbRenderer.java @@ -0,0 +1,40 @@ +package zone.moddev.mc.skysleylines.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.ItemRenderer; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemDisplayContext; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import zone.moddev.mc.skysleylines.server.EnergyOrb; + +final class EnergyOrbRenderer extends EntityRenderer { + private final ItemRenderer items; + private final ItemStack appearance = new ItemStack(Items.ECHO_SHARD); + + EnergyOrbRenderer(EntityRendererProvider.Context context) { + super(context); + items = context.getItemRenderer(); + } + + @Override public void render(EnergyOrb orb, float yaw, float partialTick, PoseStack pose, MultiBufferSource buffers, int light) { + float age = orb.tickCount + partialTick; + pose.pushPose(); + pose.translate(0, 0.35 + 0.08 * Math.sin(age * 0.12), 0); + pose.mulPose(Axis.YP.rotationDegrees(age * 4)); + pose.scale(0.65F, 0.65F, 0.65F); + items.renderStatic(appearance, ItemDisplayContext.GROUND, LightTexture.FULL_BRIGHT, OverlayTexture.NO_OVERLAY, + pose, buffers, orb.level(), orb.getId()); + pose.popPose(); + super.render(orb, yaw, partialTick, pose, buffers, light); + } + + @Override public ResourceLocation getTextureLocation(EnergyOrb orb) { return TextureAtlas.LOCATION_BLOCKS; } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/client/LeyConfigSection.java b/src/main/java/zone/moddev/mc/skysleylines/client/LeyConfigSection.java index 4f94c43..d994ed8 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/client/LeyConfigSection.java +++ b/src/main/java/zone/moddev/mc/skysleylines/client/LeyConfigSection.java @@ -15,6 +15,7 @@ /** Adds the Ley Lines controls that need more than a generic text or number box. */ final class LeyConfigSection extends ConfigurationSectionScreen { private EditBox rechargeField; + private EditBox capacityField; LeyConfigSection(Screen parent, ModConfig.Type type, ModConfig config, Component title) { super(parent, type, config, title); @@ -42,12 +43,25 @@ protected Element createIntegerValue(String key, ValueSpec spec, Supplier source, Consumer target) { + if (!LeyConfig.CAPACITY_MULTIPLIER_KEY.equals(key)) return super.createDoubleValue(key, spec, source, target); + var element = createNumberBox(key, spec, LeyConfig::configuredCapacityMultiplier, target, + LeyConfig::validCapacityMultiplier, Double::parseDouble, 0.0); + capacityField = (EditBox) element.widget(); + return element; + } + @Override public void onClose() { if (rechargeField != null && !hasValidRechargeInput()) { setFocused(rechargeField); return; } + if (capacityField != null && !hasValidCapacityInput()) { + setFocused(capacityField); + return; + } super.onClose(); } @@ -58,4 +72,12 @@ private boolean hasValidRechargeInput() { return false; } } + + private boolean hasValidCapacityInput() { + try { + return LeyConfig.validCapacityMultiplier(Double.parseDouble(capacityField.getValue())); + } catch (NumberFormatException invalidInput) { + return false; + } + } } diff --git a/src/main/java/zone/moddev/mc/skysleylines/client/LeyVisionClient.java b/src/main/java/zone/moddev/mc/skysleylines/client/LeyVisionClient.java index b4b3303..6ac9127 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/client/LeyVisionClient.java +++ b/src/main/java/zone/moddev/mc/skysleylines/client/LeyVisionClient.java @@ -52,7 +52,7 @@ public static void tick(ClientTickEvent.Post event) { spans = newSpans; var newPlumes = new ArrayList(); for (var crossing : current.crossings()) { - Vec3 centre = surface(level, crossing.x(), crossing.z()); + Vec3 centre = surface(level, crossing.x(), crossing.z(), crossing.y()); if (centre != null) { AABB bounds = new AABB(centre.add(-22, -1, -22), centre.add(22, 62, 22)); newPlumes.add(new Plume(centre, crossing.strength(), bounds)); @@ -67,12 +67,12 @@ private static void sampleReach(ClientLevel level, VisionGeometry.Reach reach, d double ux = dx / length, uz = dz / length; double offset = reach.x0() * ux + reach.z0() * uz; double at = 0; - Vec3 from = surface(level, reach.x0(), reach.z0()); + Vec3 from = surface(level, reach.x0(), reach.z0(), reach.y0()); // A world-aligned sampling grid keeps the filaments in place when the server patch moves. double next = (Math.floor(offset / STEP) + 1) * STEP - offset; while (at < length) { double end = Math.min(length, next); - Vec3 to = surface(level, reach.x0() + end * ux, reach.z0() + end * uz); + Vec3 to = surface(level, reach.x0() + end * ux, reach.z0() + end * uz, reach.heightAt(end / length)); if (from != null && to != null && end - at > 0.001) { double startFade = Math.clamp(Math.min(at, length - at) / 12, 0, 1); double endFade = Math.clamp(Math.min(end, length - end) / 12, 0, 1); @@ -85,11 +85,12 @@ private static void sampleReach(ClientLevel level, VisionGeometry.Reach reach, d } } - private static Vec3 surface(ClientLevel level, double x, double z) { + private static Vec3 surface(ClientLevel level, double x, double z, double elevation) { int bx = Mth.floor(x), bz = Mth.floor(z); var chunk = level.getChunkSource().getChunk(SectionPos.blockToSectionCoord(bx), SectionPos.blockToSectionCoord(bz), ChunkStatus.FULL, false); if (chunk == null) return null; + if (Double.isFinite(elevation)) return new Vec3(x, elevation + 0.05, z); // WORLD_SURFACE is sent to clients and puts the river above water and terrain. int y = chunk.getHeight(Heightmap.Types.WORLD_SURFACE, bx, bz); return y < level.getMinBuildHeight() ? null : new Vec3(x, y + 1.05, z); diff --git a/src/main/java/zone/moddev/mc/skysleylines/core/LeyNetwork.java b/src/main/java/zone/moddev/mc/skysleylines/core/LeyNetwork.java index 525e269..fc916de 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/core/LeyNetwork.java +++ b/src/main/java/zone/moddev/mc/skysleylines/core/LeyNetwork.java @@ -8,18 +8,37 @@ public final class LeyNetwork implements LeyFieldSampler { @FunctionalInterface public interface NaturalHeight { OptionalInt at(int x, int z); } private record Cell(int x, int z) {} - private record Segment(int cellX, int cellZ, double x, double z, double dx, double dz, double intensity) { + private record Segment(int cellX, int cellZ, double x, double z, double dx, double dz, double intensity, + double y0, double y1) { + Segment(int cellX, int cellZ, double x, double z, double dx, double dz, double intensity) { + this(cellX, cellZ, x, z, dx, dz, intensity, Double.NaN, Double.NaN); + } + + double fraction(double px, double pz) { + return Math.clamp(((px - x) * dx + (pz - z) * dz) / (dx * dx + dz * dz), 0, 1); + } + + double height(double fraction) { return y0 + fraction * (y1 - y0); } double distance(double px, double pz) { - double t = Math.clamp(((px - x) * dx + (pz - z) * dz) / (dx * dx + dz * dz), 0, 1); + double t = fraction(px, pz); return StrictMath.hypot(px - x - t * dx, pz - z - t * dz); } LineReading reading(double px, double pz) { - double t = Math.clamp(((px - x) * dx + (pz - z) * dz) / (dx * dx + dz * dz), 0, 1); + double t = fraction(px, pz); double length = StrictMath.hypot(dx, dz); return new LineReading(x + t * dx, z + t * dz, distance(px, pz), dx / length, dz / length); } } - private record Tile(List lines, List nexuses) {} + private record Tile(List lines, List nexuses, Map> byChunk) {} + private record OverlayTile(List nodes, List lines) { + static final OverlayTile EMPTY = new OverlayTile(List.of(), List.of()); + } + private record Overlay(Map tiles, Map> byChunk) { + static final Overlay EMPTY = new Overlay(Map.of(), Map.of()); + } + private static final int OVERLAY_TILE = 256; + public static final int CONNECTOR_REACH = 512; + private final Overlay overlay; private final long salt; private final String dimension; private final LeyProfile profile; @@ -29,11 +48,16 @@ private record Tile(List lines, List nexuses) {} private final Map heights = lru(8192); public LeyNetwork(long seed, String dimension, LeyProfile profile, NaturalHeight height, boolean active) { + this(seed, dimension, profile, height, active, List.of()); + } + + public LeyNetwork(long seed, String dimension, LeyProfile profile, NaturalHeight height, boolean active, List pins) { this.salt = mix(seed ^ dimensionHash(dimension) ^ 0x534B59534C455931L); this.dimension = dimension; this.profile = Objects.requireNonNull(profile); this.height = Objects.requireNonNull(height); this.active = active; + this.overlay = active && !pins.isEmpty() ? buildOverlay(pins) : Overlay.EMPTY; } private static Map lru(int limit) { return new LinkedHashMap<>(16, 0.75f, true) { @@ -98,8 +122,159 @@ private Tile compile(int cx, int cz) { nodes.add(new Nexus(new NexusId(dimension, a.cellX, a.cellZ, b.cellX, b.cellZ), x, z, strength)); } nodes.sort(Comparator.comparing(Nexus::id)); - return new Tile(List.copyOf(lines), List.copyOf(nodes)); + return new Tile(List.copyOf(lines), List.copyOf(nodes), indexChunks(nodes)); + } + private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); } + + private static Map> indexChunks(List nodes) { + var chunks = new HashMap>(); + for (var node : nodes) { + int x = Math.floorDiv((int) Math.floor(node.x()), 16); + int z = Math.floorDiv((int) Math.floor(node.z()), 16); + chunks.computeIfAbsent(key(x, z), ignored -> new ArrayList<>()).add(node); + } + chunks.replaceAll((ignored, values) -> List.copyOf(values)); + return Map.copyOf(chunks); + } + + @Override public List nexusesInChunk(int chunkX, int chunkZ) { + if (!active || chunkX < -134217728 || chunkX > 134217727 + || chunkZ < -134217728 || chunkZ > 134217727) return List.of(); + int x = chunkX * 16, z = chunkZ * 16, spacing = profile.spacingBlocks(); + int tx = Math.floorDiv(x, spacing), tz = Math.floorDiv(z, spacing); + long key = key(chunkX, chunkZ); + List seeded = tile(tx, tz).byChunk.getOrDefault(key, List.of()); + // A tile's margin can already contain the entire chunk across either boundary. + int margin = Math.max(profile.lineWidth(), profile.nexusRadius()); + if (margin < 15) { + int endX = Math.floorDiv(x + 15, spacing), endZ = Math.floorDiv(z + 15, spacing); + if (tx != endX || tz != endZ) seeded = mergeSeededChunk(key, tx, tz, endX, endZ, seeded); + } + if (overlay == Overlay.EMPTY) return seeded; + var pinned = overlay.byChunk.get(key); + if (pinned == null || seeded.isEmpty()) return pinned == null ? seeded : pinned; + var merged = new ArrayList(seeded.size() + pinned.size()); + merged.addAll(seeded); + merged.addAll(pinned); + return List.copyOf(merged); + } + + private List mergeSeededChunk(long key, int tx, int tz, int endX, int endZ, List seeded) { + TreeMap merged = null; + for (int cx = tx; cx <= endX; cx++) { + for (int cz = tz; cz <= endZ; cz++) { + if (cx == tx && cz == tz) continue; + var adjacent = tile(cx, cz).byChunk.get(key); + if (adjacent == null) continue; + if (seeded.isEmpty()) { + seeded = adjacent; + continue; + } + if (merged == null) { + merged = new TreeMap<>(); + for (var node : seeded) merged.put(node.id(), node); + } + for (var node : adjacent) merged.put(node.id(), node); + } + } + return merged == null ? seeded : List.copyOf(merged.values()); + } + + public record Connection(double x, int y, double z, double strength) {} + private record Join(Segment line, LineReading point) {} + + public List overlappingNexuses(Nexus pin) { + int radius = 2 * profile.nexusRadius(); + int x = (int) pin.x(), z = (int) pin.z(); + return java.util.stream.Stream.concat(searchTiles(x, z, radius).stream().flatMap(tile -> tile.nexuses.stream()).distinct(), + overlayNear(x, z, radius).nodes.stream()) + .filter(other -> other.distanceSquared(x, z) < (double) radius * radius) + .sorted(Comparator.comparing(Nexus::id)).toList(); } + + /** Computes joins against the seeded network alone, for preview and restart. */ + public List connections(Nexus pin) { + int x = (int) pin.x(), z = (int) pin.z(); + return searchTiles(x, z, CONNECTOR_REACH).stream().flatMap(tile -> tile.lines.stream()).distinct() + .map(line -> new Join(line, line.reading(x, z))) + .filter(join -> join.point.distance() >= 0.01 && join.point.distance() <= CONNECTOR_REACH) + .sorted(Comparator.comparingDouble((Join join) -> join.point.distance()) + .thenComparingInt(join -> join.line.cellX).thenComparingInt(join -> join.line.cellZ)) + .map(join -> { + var point = join.point; + var surface = naturalHeight((int) Math.floor(point.x()), (int) Math.floor(point.z())); + return surface.isEmpty() ? null : new Connection(point.x(), surface.getAsInt(), point.z(), + Math.min(pin.strength(), join.line.intensity)); + }).filter(Objects::nonNull).limit(2).toList(); + } + + private Overlay buildOverlay(List input) { + var pins = input.stream().sorted(Comparator.comparing(Nexus::id)).toList(); + var tiles = new HashMap(); + for (var pin : pins) { + int radius = profile.nexusRadius(); + index(tiles, pin.x() - radius, pin.z() - radius, pin.x() + radius, pin.z() + radius, pin, null); + int width = profile.lineWidth(); + for (var join : connections(pin)) { + var line = new Segment(0, 0, pin.x(), pin.z(), join.x - pin.x(), join.z - pin.z(), join.strength, + pin.authoredY().orElseThrow(), join.y); + index(tiles, Math.min(pin.x(), join.x) - width, Math.min(pin.z(), join.z) - width, + Math.max(pin.x(), join.x) + width, Math.max(pin.z(), join.z) + width, null, line); + } + } + tiles.replaceAll((ignored, tile) -> new OverlayTile(List.copyOf(tile.nodes), List.copyOf(tile.lines))); + return new Overlay(Map.copyOf(tiles), indexChunks(pins)); + } + + private static void index(Map tiles, double minX, double minZ, double maxX, double maxZ, + Nexus node, Segment line) { + for (int x = overlayCell(minX); x <= overlayCell(maxX); x++) { + for (int z = overlayCell(minZ); z <= overlayCell(maxZ); z++) { + var tile = tiles.computeIfAbsent(key(x, z), ignored -> new OverlayTile(new ArrayList<>(), new ArrayList<>())); + if (node != null) tile.nodes.add(node); else tile.lines.add(line); + } + } + } + + private static int overlayCell(double coordinate) { return (int) Math.floor(coordinate / OVERLAY_TILE); } + + private OverlayTile overlayAt(int x, int z) { + return overlay.tiles.getOrDefault(key(Math.floorDiv(x, OVERLAY_TILE), Math.floorDiv(z, OVERLAY_TILE)), OverlayTile.EMPTY); + } + + private List authoredAt(int x, int z) { + if (overlay == Overlay.EMPTY) return List.of(); + var tile = overlayAt(x, z); + if (tile == OverlayTile.EMPTY) return List.of(); + var influences = new ArrayList(); + for (var node : tile.nodes) { + double distance = Math.sqrt(node.distanceSquared(x, z)); + int radius = profile.nexusRadius(); + if (distance < radius) influences.add(new LeyColumn.Influence(node.authoredY().orElseThrow(), + node.strength() * (1 - distance / radius), distance, radius, node)); + } + for (var line : tile.lines) { + double distance = line.distance(x, z); + int width = profile.lineWidth(); + if (distance < width) influences.add(new LeyColumn.Influence(line.height(line.fraction(x, z)), + line.intensity * 0.5 * (1 - distance / width), distance, width, null)); + } + return influences; + } + + private OverlayTile overlayNear(int x, int z, int radius) { + if (overlay == Overlay.EMPTY) return OverlayTile.EMPTY; + var nodes = new LinkedHashSet(); + var lines = new LinkedHashSet(); + for (int cx = overlayCell((double) x - radius); cx <= overlayCell((double) x + radius); cx++) { + for (int cz = overlayCell((double) z - radius); cz <= overlayCell((double) z + radius); cz++) { + var tile = overlay.tiles.get(key(cx, cz)); + if (tile != null) { nodes.addAll(tile.nodes); lines.addAll(tile.lines); } + } + } + return new OverlayTile(List.copyOf(nodes), List.copyOf(lines)); + } + private OptionalInt naturalHeight(int x, int z) { long key = ((long) x << 32) ^ (z & 0xffffffffL); synchronized (heights) { @@ -132,7 +307,7 @@ private OptionalInt naturalHeight(int x, int z) { OptionalInt surface = naturalHeight(x, z); if (surface.isEmpty()) return LeyColumn.inactive(); return new LeyColumn(true, surface.getAsInt(), profile.verticalReach(), lineStrength, nexusStrength, - Optional.ofNullable(closest), distance, nexusDistance); + Optional.ofNullable(closest), distance, nexusDistance, authoredAt(x, z)); } private List searchTiles(int x, int z, int radius) { if (radius <= 0 || radius > Math.min(4096, profile.spacingBlocks() * 2)) throw new IllegalArgumentException("Search radius out of bounds"); @@ -145,13 +320,15 @@ private List searchTiles(int x, int z, int radius) { } @Override public Optional nearestNexus(int x, int z, int radius) { if (!active) return Optional.empty(); - return searchTiles(x, z, radius).stream().flatMap(t -> t.nexuses.stream()).distinct() + return java.util.stream.Stream.concat(searchTiles(x, z, radius).stream().flatMap(t -> t.nexuses.stream()).distinct(), + overlayNear(x, z, radius).nodes.stream()) .filter(n -> n.distanceSquared(x, z) <= (double) radius * radius) .min(Comparator.comparingDouble((Nexus n) -> n.distanceSquared(x, z)).thenComparing(Nexus::id)); } @Override public Optional nearestLine(int x, int z, int radius) { if (!active) return Optional.empty(); - return searchTiles(x, z, radius).stream().flatMap(t -> t.lines.stream()).distinct() + return java.util.stream.Stream.concat(searchTiles(x, z, radius).stream().flatMap(t -> t.lines.stream()).distinct(), + overlayNear(x, z, radius).lines.stream()) .map(l -> l.reading(x, z)).filter(l -> l.distance() <= radius) .min(Comparator.comparingDouble(LineReading::distance).thenComparingDouble(LineReading::x).thenComparingDouble(LineReading::z)); } @@ -161,19 +338,22 @@ public VisionGeometry vision(int x, int z) { if (!active) return VisionGeometry.EMPTY; int radius = VisionGeometry.RADIUS; List region = searchTiles(x, z, radius); - var reaches = region.stream().flatMap(tile -> tile.lines.stream()).distinct() + var nearby = overlayNear(x, z, radius); + var reaches = java.util.stream.Stream.concat(region.stream().flatMap(tile -> tile.lines.stream()).distinct(), nearby.lines.stream()) .sorted(Comparator.comparingDouble((Segment line) -> line.distance(x, z)) .thenComparingInt(Segment::cellX).thenComparingInt(Segment::cellZ)) .map(line -> clip(line, x, z, radius)).filter(Objects::nonNull) .limit(VisionGeometry.MAX_REACHES).toList(); - var crossings = region.stream().flatMap(tile -> tile.nexuses.stream()).distinct() + var crossings = java.util.stream.Stream.concat(region.stream().flatMap(tile -> tile.nexuses.stream()).distinct(), nearby.nodes.stream()) .filter(node -> node.distanceSquared(x, z) <= radius * radius) .sorted(Comparator.comparingDouble((Nexus node) -> node.distanceSquared(x, z)).thenComparing(Nexus::id)) .limit(VisionGeometry.MAX_CROSSINGS) - .map(node -> new VisionGeometry.Crossing(node.x(), node.z(), (float) node.strength())).toList(); + .map(node -> new VisionGeometry.Crossing(node.x(), node.z(), visionStrength(node.strength()), node.authoredY().isPresent() ? node.authoredY().getAsInt() : Double.NaN)).toList(); return new VisionGeometry(profile.lineWidth(), reaches, crossings); } + private static float visionStrength(double strength) { return Math.max(Float.MIN_VALUE, (float) strength); } + private static VisionGeometry.Reach clip(Segment line, double x, double z, double radius) { double length = Math.hypot(line.dx, line.dz); double ux = line.dx / length, uz = line.dz / length; @@ -184,7 +364,7 @@ private static VisionGeometry.Reach clip(Segment line, double x, double z, doubl double first = Math.max(0, along - half), last = Math.min(length, along + half); if (last - first < 0.01) return null; return new VisionGeometry.Reach(line.x + first * ux, line.z + first * uz, - line.x + last * ux, line.z + last * uz, (float) line.intensity); + line.x + last * ux, line.z + last * uz, visionStrength(line.intensity), line.height(first / length), line.height(last / length)); } public int cachedTileCount() { synchronized (tiles) { return tiles.size(); } } public int cachedHeightCount() { synchronized (heights) { return heights.size(); } } diff --git a/src/main/java/zone/moddev/mc/skysleylines/core/NexusLedger.java b/src/main/java/zone/moddev/mc/skysleylines/core/NexusLedger.java index 500918c..11443dc 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/core/NexusLedger.java +++ b/src/main/java/zone/moddev/mc/skysleylines/core/NexusLedger.java @@ -7,9 +7,13 @@ /** Sparse exact accounts. The owning server thread serializes all access. */ public final class NexusLedger { + public static final int CAPACITY_SCALE_ONE = 10_000; + private static final int MAX_CAPACITY = 1_000_000; + + /** Stored capacity is the unscaled capacity derived from the saved profile and nexus strength. */ public record Balance(int capacity, MagicAmount raw, Map currencies, long tick) { public Balance { - if (capacity <= 0 || capacity > 1_000_000 || tick < 0 || raw == null) throw new IllegalArgumentException("Invalid nexus balance"); + if (capacity <= 0 || capacity > MAX_CAPACITY || tick < 0 || raw == null) throw new IllegalArgumentException("Invalid nexus balance"); var copy = new TreeMap(); currencies.forEach((id, units) -> { Objects.requireNonNull(id); @@ -26,19 +30,35 @@ default void failure(RuntimeException failure) {} } public static final Hooks NO_HOOKS = new Hooks() {}; private int rechargeTicks; + private int capacityScale; private Map balances = new TreeMap<>(); private Map rates = new TreeMap<>(); private Set active = Set.of(); private long revision; private boolean transaction; public NexusLedger(int rechargeTicks) { + this(rechargeTicks, CAPACITY_SCALE_ONE); + } + public NexusLedger(int rechargeTicks, int capacityScale) { checkRechargeTicks(rechargeTicks); + checkCapacityScale(capacityScale); this.rechargeTicks = rechargeTicks; + this.capacityScale = capacityScale; } private static void checkRechargeTicks(int ticks) { if (ticks <= 0 || ticks > 1_728_000) throw new IllegalArgumentException("Invalid recovery time"); } + private static void checkCapacityScale(int scale) { + if (scale <= 0 || scale > CAPACITY_SCALE_ONE * 100) throw new IllegalArgumentException("Invalid nexus capacity multiplier"); + } public int rechargeTicks() { return rechargeTicks; } + public int capacityScale() { return capacityScale; } + public double capacityMultiplier() { return (double) capacityScale / CAPACITY_SCALE_ONE; } + public int scaledCapacity(int base) { return scaledCapacity(base, capacityScale); } + private static int scaledCapacity(int base, int scale) { + long rounded = ((long) base * scale + CAPACITY_SCALE_ONE / 2) / CAPACITY_SCALE_ONE; + return (int) Math.clamp(rounded, 1, MAX_CAPACITY); + } public Map entries() { return Collections.unmodifiableMap(new TreeMap<>(balances)); } public Map rates() { return Collections.unmodifiableMap(new TreeMap<>(rates)); } public Set activeCurrencies() { return active; } @@ -63,9 +83,12 @@ public boolean bindCurrencies(Map configured) { return added; } public void restore(NexusId id, Balance balance) { + restore(id, balance, false); + } + public void restore(NexusId id, Balance balance, boolean allowsRetainedCharge) { if (transaction) throw new IllegalStateException("Nested ledger mutation"); LeySaveLimits.checkAmount(balance.raw, "Raw balance for " + id); - validate(balance, rates); + validate(balance, rates, allowsRetainedCharge ? MAX_CAPACITY : balance.capacity); if (balances.putIfAbsent(id, balance) != null) throw new IllegalArgumentException("Duplicate nexus balance"); } private static MagicAmount converted(Balance b, Map rates) { @@ -77,21 +100,31 @@ private static MagicAmount converted(Balance b, Map rates) { - if (b.raw.add(converted(b, rates)).compareTo(MagicAmount.of(b.capacity)) > 0) throw new IllegalArgumentException("Account exceeds shared capacity"); + private static void validate(Balance b, Map rates, int capacity) { + if (b.raw.add(converted(b, rates)).compareTo(MagicAmount.of(capacity)) > 0) throw new IllegalArgumentException("Account exceeds shared capacity"); + } + private AccountSnapshot snapshot(Balance b, Map rates) { + return new AccountSnapshot(scaledCapacity(b.capacity), b.raw, b.currencies, b.raw.add(converted(b, rates)), b.tick); } - private static AccountSnapshot snapshot(Balance b, Map rates) { - return new AccountSnapshot(b.capacity, b.raw, b.currencies, b.raw.add(converted(b, rates)), b.tick); + private MagicAmount rawLimit(int capacity, Balance balance) { + var maximum = MagicAmount.of(capacity); + var reserved = converted(balance, rates); + return maximum.compareTo(reserved) > 0 ? maximum.subtract(reserved) : MagicAmount.ZERO; } private Balance projected(NexusId id, int capacity, long now) { - if (capacity <= 0 || capacity > 1_000_000 || now < 0) throw new IllegalArgumentException("Invalid quote"); + if (capacity <= 0 || capacity > MAX_CAPACITY || now < 0) throw new IllegalArgumentException("Invalid quote"); + int effectiveCapacity = scaledCapacity(capacity); Balance stored = balances.get(id); - if (stored == null) return new Balance(capacity, MagicAmount.of(capacity), Map.of(), now); + if (stored == null) return new Balance(capacity, MagicAmount.of(effectiveCapacity), Map.of(), now); if (stored.capacity != capacity) throw new IllegalStateException("Saved capacity differs from immutable world profile"); long tick = Math.max(now, stored.tick); + if (tick == stored.tick) return stored; long elapsed = Math.min(tick - stored.tick, rechargeTicks); - MagicAmount roomForRaw = MagicAmount.of(capacity).subtract(converted(stored, rates)); - MagicAmount raw = stored.raw.add(MagicAmount.fraction(elapsed * capacity, rechargeTicks)).min(roomForRaw); + MagicAmount roomForRaw = rawLimit(effectiveCapacity, stored); + MagicAmount raw = stored.raw; + if (raw.compareTo(roomForRaw) < 0) { + raw = raw.add(MagicAmount.fraction(elapsed * effectiveCapacity, rechargeTicks)).min(roomForRaw); + } return new Balance(capacity, raw, stored.currencies, tick); } public AccountSnapshot quote(NexusId id, int capacity, long now) { return snapshot(projected(id, capacity, now), rates); } @@ -126,8 +159,10 @@ public PowerResult transact(NexusId id, int capacity, long now, PowerRequest req } Balance after = new Balance(capacity, raw, currencies, before.tick); LeySaveLimits.checkAmount(after.raw, "Raw balance for " + id); - validate(after, rates); - var receipt = new PowerResult.Receipt(Math.incrementExact(revision), id, request, snapshot(before, rates), snapshot(after, rates)); + var previous = snapshot(before, rates); + var next = snapshot(after, rates); + if (next.total().compareTo(previous.total()) > 0) throw new IllegalStateException("Transaction increased total nexus charge"); + var receipt = new PowerResult.Receipt(Math.incrementExact(revision), id, request, previous, next); try { if (!hooks.before(receipt)) return PowerResult.failed(CANCELLED); } catch (RuntimeException failedCheck) { report(hooks, failedCheck); return PowerResult.failed(CHECK_FAILED); } balances.put(id, after); revision = receipt.revision(); @@ -136,31 +171,121 @@ public PowerResult transact(NexusId id, int capacity, long now, PowerRequest req } catch (ArithmeticException overflow) { return PowerResult.failed(OVERFLOW); } } finally { transaction = false; } } + /** Keep node, pool, and drop observers under the same mutation guard. */ + public T exclusive(T rejected, java.util.function.Supplier action) { + if (transaction) return rejected; + transaction = true; + try { return action.get(); } finally { transaction = false; } + } + + public boolean mutationInProgress() { return transaction; } + + public MagicAmount savedRate(ResourceLocation currency) { return rates.get(currency); } + + /** Called only inside exclusive, after all companion changes have been prepared. */ + public Runnable prepareDeposit(NexusId id, int capacity, long now, ResourceLocation currency, long units) { + if (!transaction) throw new IllegalStateException("Deposit requires a coordinated mutation"); + Balance before = projected(id, capacity, now); + var currencies = new TreeMap<>(before.currencies); + currencies.put(currency, Math.addExact(currencies.getOrDefault(currency, 0L), units)); + Balance after = new Balance(capacity, before.raw, currencies, before.tick); + LeySaveLimits.checkAmount(after.raw, "Raw balance for " + id); + validate(after, rates, scaledCapacity(capacity)); + long next = Math.incrementExact(revision); + return () -> { + balances.put(id, after); + revision = next; + }; + } + + public enum RecoveryStatus { SUCCESS, REENTRANT, UNKNOWN_CURRENCY, INVALID_AMOUNT, INSUFFICIENT_CURRENCY, + CAPACITY, OVERFLOW, CANCELLED, CHECK_FAILED } + + public interface RecoveryHooks { + boolean before(NexusRecoveryEvent.Receipt receipt); + void after(NexusRecoveryEvent.Receipt receipt); + void failure(RuntimeException failure); + } + + public RecoveryStatus recover(NexusId from, net.minecraft.core.BlockPos fromPos, NexusId to, + net.minecraft.core.BlockPos toPos, int targetCapacity, long now, + ResourceLocation currency, long units, PowerRequest.Consumer consumer, RecoveryHooks hooks) { + return exclusive(RecoveryStatus.REENTRANT, () -> { + if (units <= 0 || from.equals(to)) return RecoveryStatus.INVALID_AMOUNT; + var rate = rates.get(currency); + if (rate == null) return RecoveryStatus.UNKNOWN_CURRENCY; + var source = balances.get(from); + if (source == null || source.currencies.getOrDefault(currency, 0L) < units) return RecoveryStatus.INSUFFICIENT_CURRENCY; + var target = projected(to, targetCapacity, now); + var targetSnapshot = snapshot(target, rates); + if (targetSnapshot.total().add(MagicAmount.of(units).divide(rate)).compareTo(MagicAmount.of(targetSnapshot.capacity())) > 0) + return RecoveryStatus.CAPACITY; + try { + var debited = new TreeMap<>(source.currencies); + debited.compute(currency, (id, held) -> held - units); + var credited = new TreeMap<>(target.currencies); + credited.merge(currency, units, Math::addExact); + var sourceAfter = new Balance(source.capacity, source.raw, debited, source.tick); + var targetAfter = new Balance(targetCapacity, target.raw, credited, target.tick); + LeySaveLimits.checkAmount(targetAfter.raw, "Raw balance for " + to); + var receipt = new NexusRecoveryEvent.Receipt(Math.incrementExact(revision), currency, units, consumer, + new NexusRecoveryEvent.Account(from, fromPos, snapshot(source, rates), snapshot(sourceAfter, rates)), + new NexusRecoveryEvent.Account(to, toPos, targetSnapshot, snapshot(targetAfter, rates))); + try { + if (!hooks.before(receipt)) return RecoveryStatus.CANCELLED; + } catch (RuntimeException failure) { + hooks.failure(failure); + return RecoveryStatus.CHECK_FAILED; + } + balances.put(from, sourceAfter); + balances.put(to, targetAfter); + revision = receipt.revision(); + try { hooks.after(receipt); } catch (RuntimeException failure) { hooks.failure(failure); } + return RecoveryStatus.SUCCESS; + } catch (ArithmeticException failure) { + return RecoveryStatus.OVERFLOW; + } + }); + } + private static void report(Hooks hooks, RuntimeException failure) { try { hooks.failure(failure); } catch (RuntimeException ignored) { /* Diagnostics cannot change committed payment semantics. */ } } /** Only full accounts containing entirely raw magic can be omitted. */ public void pruneRecovered(long now) { if (transaction) throw new IllegalStateException("Nested ledger mutation"); - balances.entrySet().removeIf(e -> e.getValue().currencies.isEmpty() && projected(e.getKey(), e.getValue().capacity, now).raw.equals(MagicAmount.of(e.getValue().capacity))); + balances.entrySet().removeIf(e -> e.getValue().currencies.isEmpty() + && projected(e.getKey(), e.getValue().capacity, now).raw.equals(MagicAmount.of(scaledCapacity(e.getValue().capacity)))); } /** Settle at the current rate and commit the new interval only after a successful backup. */ public boolean changeRechargeTicks(int ticks, long now, Runnable backup) { + return changeLimits(ticks, capacityScale, now, backup); + } + + /** Settle using the previous limits before preparing the replacement ledger. */ + public boolean changeLimits(int ticks, int scale, long now, Runnable backup) { if (transaction) throw new IllegalStateException("Nested ledger mutation"); checkRechargeTicks(ticks); - if (ticks == rechargeTicks) return false; + checkCapacityScale(scale); + if (ticks == rechargeTicks && scale == capacityScale) return false; transaction = true; try { long nextRevision = Math.incrementExact(revision); var next = new TreeMap(); balances.forEach((id, stored) -> { Balance settled = projected(id, stored.capacity, now); + int newCapacity = scaledCapacity(stored.capacity, scale); + if (newCapacity < scaledCapacity(stored.capacity)) { + var raw = settled.raw.min(rawLimit(newCapacity, settled)); + settled = new Balance(settled.capacity, raw, settled.currencies, settled.tick); + } LeySaveLimits.checkAmount(settled.raw, "Raw balance for " + id); next.put(id, settled); }); backup.run(); balances = next; rechargeTicks = ticks; + capacityScale = scale; revision = nextRevision; return true; } finally { @@ -192,7 +317,6 @@ private Map migrated(Map target } Balance next = new Balance(old.capacity, raw, currencies, old.tick); LeySaveLimits.checkAmount(next.raw, "Migrated raw balance for " + entry.getKey()); - validate(next, target); if (!snapshot(old, rates).total().equals(snapshot(next, target).total())) throw new IllegalStateException("Migration changed energy"); result.put(entry.getKey(), next); } diff --git a/src/main/java/zone/moddev/mc/skysleylines/core/PlayerEnergyDefaults.java b/src/main/java/zone/moddev/mc/skysleylines/core/PlayerEnergyDefaults.java new file mode 100644 index 0000000..9ce5182 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/core/PlayerEnergyDefaults.java @@ -0,0 +1,27 @@ +package zone.moddev.mc.skysleylines.core; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import net.minecraft.resources.ResourceLocation; +import zone.moddev.mc.skysleylines.api.PlayerPowerService.CurrencyPolicy; + +public final class PlayerEnergyDefaults { + private static final Map POLICIES = new HashMap<>(); + private static boolean frozen; + + private PlayerEnergyDefaults() {} + + public static synchronized void register(ResourceLocation currency, CurrencyPolicy policy) { + if (frozen) throw new IllegalStateException("Player energy defaults are frozen"); + LeySaveLimits.checkCurrencyId(currency); + if (POLICIES.putIfAbsent(currency, Objects.requireNonNull(policy)) != null) + throw new IllegalArgumentException("Duplicate player energy defaults for " + currency); + } + + public static synchronized void freeze() { frozen = true; } + + public static synchronized CurrencyPolicy forCurrency(ResourceLocation currency) { + return POLICIES.getOrDefault(currency, CurrencyPolicy.DEFAULT); + } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/core/VisionGeometry.java b/src/main/java/zone/moddev/mc/skysleylines/core/VisionGeometry.java index 50c05ce..2000ca6 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/core/VisionGeometry.java +++ b/src/main/java/zone/moddev/mc/skysleylines/core/VisionGeometry.java @@ -17,10 +17,19 @@ public record VisionGeometry(int lineHalfWidth, List reaches, List 2 * RADIUS + 0.001) { throw new IllegalArgumentException("Invalid ley vision reach length"); @@ -28,8 +37,17 @@ public record Reach(double x0, double z0, double x1, double z1, float strength) } } - public record Crossing(double x, double z, float strength) { - public Crossing { checkPoint(x, z, strength); } + public record Crossing(double x, double z, float strength, double y) { + public Crossing(double x, double z, float strength) { this(x, z, strength, Double.NaN); } + + public Crossing { + checkPoint(x, z, strength); + checkElevation(y); + } + } + + private static void checkElevation(double y) { + if (!Double.isNaN(y) && !(Math.abs(y) <= 4096)) throw new IllegalArgumentException("Invalid ley elevation"); } private static void checkPoint(double x, double z, float strength) { diff --git a/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyKubeEvents.java b/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyKubeEvents.java new file mode 100644 index 0000000..e9d25c3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyKubeEvents.java @@ -0,0 +1,74 @@ +package zone.moddev.mc.skysleylines.integration.kubejs; + +import dev.latvian.mods.kubejs.event.EventGroup; +import dev.latvian.mods.kubejs.event.EventHandler; +import dev.latvian.mods.kubejs.event.KubeEvent; +import dev.latvian.mods.kubejs.script.ScriptType; +import java.util.Map; +import net.minecraft.server.MinecraftServer; +import zone.moddev.mc.skysleylines.api.PlayerPowerEvent; + +public final class LeyKubeEvents { + static final EventGroup GROUP = EventGroup.of("LeyLinesEvents"); + private static final EventHandler CHANGE = GROUP.server("playerPoolChanged", () -> PoolChanged.class); + private static final EventHandler EMPTY = GROUP.server("playerPoolEmpty", () -> PoolChanged.class); + private static final EventHandler JOB = GROUP.server("energyJobFinished", () -> JobFinished.class); + + private LeyKubeEvents() {} + + public static final class PoolChanged implements KubeEvent { + private final MinecraftServer server; + private final String playerId; + private final String currency; + private final Map before; + private final Map after; + private final Map receipt; + + PoolChanged(MinecraftServer server, String playerId, String currency, Map before, + Map after, Map receipt) { + this.server = server; + this.playerId = playerId; + this.currency = currency; + this.before = before; + this.after = after; + this.receipt = receipt; + } + + public MinecraftServer getServer() { return server; } + public String getPlayerId() { return playerId; } + public String getCurrency() { return currency; } + public Map getBefore() { return before; } + public Map getAfter() { return after; } + public Map getReceipt() { return receipt; } + } + + public static final class JobFinished implements KubeEvent { + private final MinecraftServer server; + private final Map job; + + JobFinished(MinecraftServer server, Map job) { + this.server = server; + this.job = job; + } + + public MinecraftServer getServer() { return server; } + public Map getJob() { return job; } + } + + static void changed(PlayerPowerEvent.Post event) { + if (!CHANGE.hasListeners() && !EMPTY.hasListeners()) return; + var receipt = event.receipt(); + receipt.changes().forEach((id, change) -> { + if (change.before().equals(change.after())) return; + var script = new PoolChanged(event.server(), id.toString(), receipt.currency().toString(), + LeyPlayerScripts.pool(change.before()), LeyPlayerScripts.pool(change.after()), LeyPlayerScripts.receipt(receipt)); + if (CHANGE.hasListeners()) CHANGE.post(ScriptType.SERVER, script); + if (change.before().balance() > 0 && change.after().balance() == 0 && EMPTY.hasListeners()) + EMPTY.post(ScriptType.SERVER, script); + }); + } + + static void finished(PlayerPowerEvent.JobFinished event) { + if (JOB.hasListeners()) JOB.post(ScriptType.SERVER, new JobFinished(event.server(), LeyPlayerScripts.job(event.job()))); + } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyKubePlugin.java b/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyKubePlugin.java index e2ceccd..b0ab70e 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyKubePlugin.java +++ b/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyKubePlugin.java @@ -1,10 +1,23 @@ package zone.moddev.mc.skysleylines.integration.kubejs; import dev.latvian.mods.kubejs.plugin.KubeJSPlugin; +import dev.latvian.mods.kubejs.event.EventGroupRegistry; +import net.neoforged.neoforge.common.NeoForge; import dev.latvian.mods.kubejs.script.BindingRegistry; /** Loaded by KubeJS when present. The ordinary mod entry point does not link to this class. */ public final class LeyKubePlugin implements KubeJSPlugin { + @Override + public void init() { + NeoForge.EVENT_BUS.addListener(LeyKubeEvents::changed); + NeoForge.EVENT_BUS.addListener(LeyKubeEvents::finished); + } + + @Override + public void registerEvents(EventGroupRegistry registry) { + registry.register(LeyKubeEvents.GROUP); + } + @Override public void registerBindings(BindingRegistry bindings) { switch (bindings.type()) { diff --git a/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyPlayerScripts.java b/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyPlayerScripts.java new file mode 100644 index 0000000..a9eb304 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyPlayerScripts.java @@ -0,0 +1,147 @@ +package zone.moddev.mc.skysleylines.integration.kubejs; + +import java.util.*; +import net.minecraft.core.BlockPos; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import zone.moddev.mc.skysleylines.api.*; +import zone.moddev.mc.skysleylines.api.PlayerPowerService.*; +import static zone.moddev.mc.skysleylines.integration.kubejs.LeyServerScripts.exactAmount; +import static zone.moddev.mc.skysleylines.integration.kubejs.LeyServerScripts.namespaced; + +public class LeyPlayerScripts { + public Map inspectPlayer(ServerPlayer player) { + return inspectPlayer(player.serverLevel().getServer(), player.getUUID().toString()); + } + + public Map inspectPlayer(MinecraftServer server, String uuid) { + var snapshot = LeyLinesApi.playerPower(server).inspect(UUID.fromString(uuid)); + var pools = new TreeMap(); + snapshot.pools().forEach((id, pool) -> pools.put(id.toString(), pool(pool))); + return Map.of("player", snapshot.player().toString(), "pools", Collections.unmodifiableMap(pools)); + } + + public Map configurePool(ServerPlayer player, String currency, boolean enabled, Object capacity, + Object autoRate, String source) { + return result(power(player).configure(player.getUUID(), namespaced(currency), + new Settings(enabled, whole(capacity), autoRate == null ? null : exactAmount(autoRate)), consumer(source, player))); + } + + public Map setPoolBalance(ServerPlayer player, String currency, Object units, String source) { + return result(power(player).setBalance(player.getUUID(), namespaced(currency), whole(units), consumer(source, player))); + } + + public Map adjustPool(ServerPlayer player, String currency, Object delta, String source) { + if (!(delta instanceof CharSequence value) || !value.toString().matches("0|-?[1-9][0-9]{0,18}")) + throw new IllegalArgumentException("Expected signed whole-unit string"); + return result(power(player).adjust(player.getUUID(), namespaced(currency), + Long.parseLong(value.toString()), consumer(source, player))); + } + + public Map spendPool(ServerPlayer player, String currency, Object units, String source) { + return result(power(player).spend(player.getUUID(), namespaced(currency), whole(units), consumer(source, player))); + } + + public Map withdrawToPlayer(ServerPlayer player, String currency, Object units, String schedule, String source) { + return submission(power(player).withdraw(player, namespaced(currency), whole(units), timing(schedule), consumer(source, player))); + } + + public Map depositFromPlayer(ServerPlayer player, String currency, Object units, String schedule, String source) { + return submission(power(player).deposit(player, namespaced(currency), whole(units), timing(schedule), consumer(source, player))); + } + + public Map transferPlayers(ServerPlayer from, ServerPlayer to, String currency, Object units, String schedule, String source) { + return submission(power(from).transfer(from, to, namespaced(currency), whole(units), timing(schedule), consumer(source, from))); + } + + public Map scheduleConversion(ServerLevel level, int x, int y, int z, String currency, + Object raw, String schedule, String source, ServerPlayer actor) { + if (actor != null && actor.serverLevel() != level) throw new IllegalArgumentException("The actor must be in the supplied level"); + return submission(LeyLinesApi.playerPower(level.getServer()).convert(level, new BlockPos(x, y, z), namespaced(currency), + exactAmount(raw), timing(schedule), consumer(source, actor))); + } + + public Map scheduleConversion(ServerLevel level, int x, int y, int z, String currency, + Object raw, String schedule, String source) { + return scheduleConversion(level, x, y, z, currency, raw, schedule, source, null); + } + + public Map energyJob(MinecraftServer server, String id) { + return LeyLinesApi.playerPower(server).job(UUID.fromString(id)).map(LeyPlayerScripts::job).orElse(null); + } + + public boolean cancelEnergyJob(MinecraftServer server, String id) { + return LeyLinesApi.playerPower(server).cancel(UUID.fromString(id)); + } + + private static PlayerPowerService power(ServerPlayer player) { return LeyLinesApi.playerPower(player.serverLevel().getServer()); } + private static long whole(Object input) { return exactAmount(input).wholeLongExact(); } + private static PowerRequest.Consumer consumer(String id, ServerPlayer player) { + return new PowerRequest.Consumer(namespaced(id), Optional.ofNullable(player).map(ServerPlayer::getUUID)); + } + private static Timing timing(String text) { + if (text.equals("instant")) return Timing.instant(); + if (text.startsWith("duration:")) return Timing.duration(Integer.parseInt(text.substring(9))); + if (text.startsWith("rate:")) return Timing.rate(exactAmount(text.substring(5))); + throw new IllegalArgumentException("Expected 'instant', 'duration:20', or 'rate:3/2'"); + } + + private static Map result(Result result) { + return reply(result.status(), Optional.empty(), result.receipt()); + } + private static Map submission(Submission submission) { + return reply(submission.status(), submission.jobId(), submission.receipt()); + } + private static Map reply(Status status, Optional id, Optional receipt) { + var response = new LinkedHashMap(); + response.put("succeeded", status == Status.SUCCESS); + response.put("status", status.name()); + id.ifPresent(value -> response.put("jobId", value.toString())); + receipt.ifPresent(value -> response.put("receipt", receipt(value))); + return Collections.unmodifiableMap(response); + } + + static Map pool(Pool pool) { + var result = new LinkedHashMap(); + result.put("units", Long.toString(pool.balance())); + result.put("capacity", Long.toString(pool.settings().capacity())); + result.put("enabled", pool.settings().enabled()); + result.put("active", pool.active()); + if (pool.settings().autoRate() != null) result.put("autoRate", pool.settings().autoRate().toString()); + return Collections.unmodifiableMap(result); + } + + static Map receipt(Receipt receipt) { + var result = new LinkedHashMap(); + result.put("operation", receipt.operation().name()); + result.put("currency", receipt.currency().toString()); + result.put("amount", receipt.amount().toString()); + result.put("source", receipt.consumer().id().toString()); + receipt.consumer().actor().ifPresent(id -> result.put("actor", id.toString())); + receipt.nexus().ifPresent(id -> result.put("nexus", id.toString())); + receipt.drop().ifPresent(id -> result.put("drop", id.toString())); + receipt.anchor().ifPresent(anchor -> result.put("anchor", Map.of("dimension", anchor.dimension().location().toString(), + "x", anchor.pos().getX(), "y", anchor.pos().getY(), "z", anchor.pos().getZ()))); + receipt.nexusBefore().ifPresent(account -> result.put("nexusBefore", account(account))); + receipt.nexusAfter().ifPresent(account -> result.put("nexusAfter", account(account))); + var changes = new TreeMap(); + receipt.changes().forEach((id, change) -> + changes.put(id.toString(), Map.of("before", pool(change.before()), "after", pool(change.after())))); + result.put("changes", Collections.unmodifiableMap(changes)); + return Collections.unmodifiableMap(result); + } + + private static Map account(AccountSnapshot account) { + var currencies = new TreeMap(); + account.currencies().forEach((id, units) -> currencies.put(id.toString(), Long.toString(units))); + return Map.of("raw", account.raw().toString(), "total", account.total().toString(), "capacity", account.capacity(), + "currencies", Collections.unmodifiableMap(currencies)); + } + + static Map job(Job job) { + return Map.of("id", job.id().toString(), "operation", job.operation().name(), "currency", job.currency().toString(), + "total", job.total().toString(), "completed", job.completed().toString(), + "status", job.status().name(), "source", job.consumer().id().toString()); + } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyServerScripts.java b/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyServerScripts.java index dc135d8..7f3d623 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyServerScripts.java +++ b/src/main/java/zone/moddev/mc/skysleylines/integration/kubejs/LeyServerScripts.java @@ -18,7 +18,7 @@ import zone.moddev.mc.skysleylines.core.LeySaveLimits; /** Server script binding. Uses exact strings for quantities that JavaScript numbers cannot preserve. */ -public final class LeyServerScripts { +public final class LeyServerScripts extends LeyPlayerScripts { private static final Pattern EXACT_AMOUNT = Pattern.compile("(0|[1-9][0-9]*)(?:/([1-9][0-9]*))?"); static ResourceLocation namespaced(String id) { diff --git a/src/main/java/zone/moddev/mc/skysleylines/network/VisionPayload.java b/src/main/java/zone/moddev/mc/skysleylines/network/VisionPayload.java index d454913..abf25cd 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/network/VisionPayload.java +++ b/src/main/java/zone/moddev/mc/skysleylines/network/VisionPayload.java @@ -30,12 +30,15 @@ private static void write(RegistryFriendlyByteBuf buffer, VisionPayload payload) buffer.writeDouble(reach.x1()); buffer.writeDouble(reach.z1()); buffer.writeFloat(reach.strength()); + buffer.writeDouble(reach.y0()); + buffer.writeDouble(reach.y1()); } buffer.writeByte(geometry.crossings().size()); for (var crossing : geometry.crossings()) { buffer.writeDouble(crossing.x()); buffer.writeDouble(crossing.z()); buffer.writeFloat(crossing.strength()); + buffer.writeDouble(crossing.y()); } } @@ -49,13 +52,13 @@ private static VisionPayload read(RegistryFriendlyByteBuf buffer) { var reaches = new ArrayList(reachCount); for (int i = 0; i < reachCount; i++) { reaches.add(new VisionGeometry.Reach(buffer.readDouble(), buffer.readDouble(), buffer.readDouble(), - buffer.readDouble(), buffer.readFloat())); + buffer.readDouble(), buffer.readFloat(), buffer.readDouble(), buffer.readDouble())); } int crossingCount = buffer.readUnsignedByte(); if (crossingCount > VisionGeometry.MAX_CROSSINGS) throw new IllegalArgumentException("Too many ley crossings"); var crossings = new ArrayList(crossingCount); for (int i = 0; i < crossingCount; i++) { - crossings.add(new VisionGeometry.Crossing(buffer.readDouble(), buffer.readDouble(), buffer.readFloat())); + crossings.add(new VisionGeometry.Crossing(buffer.readDouble(), buffer.readDouble(), buffer.readFloat(), buffer.readDouble())); } return new VisionPayload(dimension, new VisionGeometry(width, reaches, crossings), style); } diff --git a/src/main/java/zone/moddev/mc/skysleylines/server/EnergyJobs.java b/src/main/java/zone/moddev/mc/skysleylines/server/EnergyJobs.java new file mode 100644 index 0000000..2e129ed --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/server/EnergyJobs.java @@ -0,0 +1,296 @@ +package zone.moddev.mc.skysleylines.server; + +import java.util.*; +import net.minecraft.core.BlockPos; +import net.minecraft.core.GlobalPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import zone.moddev.mc.skysleylines.api.*; +import zone.moddev.mc.skysleylines.api.PlayerPowerService.*; +import static zone.moddev.mc.skysleylines.api.PlayerPowerService.Status.*; + +/** Due-order, server-thread scheduling. Every tranche uses current balances and protection hooks. */ +final class EnergyJobs { + private static final int MAX_ACTIVE = 4096; + private static final int TICK_BUDGET = 256; + private static final int FINISHED_HISTORY = 1024; + private static final PowerRequest.Consumer AUTO = PlayerEnergy.system("auto_recharge"); + private final PlayerEnergy power; + private final Map active = new LinkedHashMap<>(); + private final Map finished = new LinkedHashMap<>(); + private final NavigableSet due = new TreeSet<>(Comparator.comparingLong((Work job) -> job.nextTick) + .thenComparingLong(job -> job.order)); + private final Map auto = new HashMap<>(); + private long order; + + private record AutoKey(UUID player, ResourceLocation currency) {} + private record Endpoint(UUID player, ResourceLocation dimension) { + Endpoint(ServerPlayer player) { this(player.getUUID(), player.serverLevel().dimension().location()); } + } + private static final class Work { + final UUID id = UUID.randomUUID(); + final Operation operation; + final ResourceLocation currency; + final MagicAmount total; + final MagicAmount speed; + final PowerRequest.Consumer consumer; + final Endpoint first; + final Endpoint second; + final ResourceLocation dimension; + final BlockPos position; + final long order; + final AutoKey automatic; + MagicAmount completed = MagicAmount.ZERO; + MagicAmount conversionRate; + NexusId nexus; + BlockPos nexusChunk; + long nextTick; + long elapsed; + + Work(Operation operation, ResourceLocation currency, MagicAmount total, MagicAmount speed, + PowerRequest.Consumer consumer, Endpoint first, Endpoint second, ResourceLocation dimension, + BlockPos position, long order, AutoKey automatic) { + this.operation = operation; + this.currency = Objects.requireNonNull(currency); + this.total = Objects.requireNonNull(total); + this.speed = speed; + this.consumer = Objects.requireNonNull(consumer); + this.first = first; + this.second = second; + this.dimension = dimension; + this.position = position; + this.order = order; + this.automatic = automatic; + } + + Job view(Status status) { return new Job(id, operation, currency, total, completed, status, consumer); } + } + + EnergyJobs(PlayerEnergy power) { this.power = power; } + + Submission submit(Operation operation, ServerPlayer from, ServerPlayer to, ServerLevel level, BlockPos pos, + ResourceLocation currency, long units, Timing timing, PowerRequest.Consumer consumer) { + power.checkThread(); + if (units <= 0) return submission(INVALID_AMOUNT); + return submit(operation, from, to, level, pos, currency, MagicAmount.of(units), timing, consumer); + } + + Submission submit(Operation operation, ServerPlayer from, ServerPlayer to, ServerLevel level, BlockPos pos, + ResourceLocation currency, MagicAmount amount, Timing timing, PowerRequest.Consumer consumer) { + return submit(operation, from, to, level, pos, currency, amount, timing, consumer, null); + } + + private Submission submit(Operation operation, ServerPlayer from, ServerPlayer to, ServerLevel level, BlockPos pos, + ResourceLocation currency, MagicAmount amount, Timing timing, PowerRequest.Consumer consumer, + AutoKey automatic) { + power.checkThread(); + if (power.busy()) return submission(REENTRANT); + if (amount.isZero() || (from != null && from == to)) return submission(INVALID_AMOUNT); + if ((from != null && power.online(from.getUUID()) != from) || (to != null && power.online(to.getUUID()) != to) + || (level != null && power.level(level.dimension().location()) != level)) return submission(UNAVAILABLE); + var speed = timing.isInstant() ? null : timing.perTick() == null ? amount.divide(MagicAmount.of(timing.ticks())) : timing.perTick(); + var job = new Work(operation, currency, amount, speed, consumer, from == null ? null : new Endpoint(from), + to == null ? null : new Endpoint(to), level == null ? null : level.dimension().location(), + pos == null ? null : pos.immutable(), order++, automatic); + var check = resolve(job); + if (check.status != SUCCESS) return submission(check.status); + if (operation == Operation.CONVERT) { + job.conversionRate = power.rate(currency); + MagicAmount output = amount.multiply(job.conversionRate); + if (output.compareTo(MagicAmount.ONE) < 0 || (timing.isInstant() && !output.isWhole())) return submission(INVALID_AMOUNT); + } + if (check.node != null) { + job.nexus = check.node.nexus().id(); + job.nexusChunk = BlockPos.containing(check.node.nexus().x(), check.pos.getY(), check.node.nexus().z()); + } + if (timing.isInstant()) { + var tranche = tranche(job, check, amount, false); + return new Submission(tranche.status, Optional.empty(), tranche.receipt); + } + if (active.size() >= MAX_ACTIVE) return submission(UNAVAILABLE); + job.nextTick = power.now() + 1; + active.put(job.id, job); + due.add(job); + if (automatic != null) auto.put(automatic, job.id); + return new Submission(RUNNING, Optional.of(job.id), Optional.empty()); + } + + private static Submission submission(Status status) { return new Submission(status, Optional.empty(), Optional.empty()); } + + private record Resolved(Status status, ServerLevel level, BlockPos pos, NexusSnapshot node) { + Resolved(Status status) { this(status, null, null, null); } + } + + private Status validateEndpoint(Endpoint endpoint, ServerPlayer player, ResourceLocation currency) { + if (endpoint == null) return SUCCESS; + if (player == null) return DISCONNECTED; + if (!player.isAlive()) return DIED; + if (!player.serverLevel().dimension().location().equals(endpoint.dimension())) return MOVED; + return power.pool(endpoint.player(), currency).active() ? SUCCESS : DORMANT; + } + + private Resolved resolve(Work job) { + var rate = power.rate(job.currency); + if (rate == null) return new Resolved(UNKNOWN_CURRENCY); + if (!power.currencyActive(job.currency)) return new Resolved(DORMANT); + if (job.conversionRate != null && !job.conversionRate.equals(rate)) return new Resolved(POLICY); + ServerPlayer first = job.first == null ? null : power.online(job.first.player()); + ServerPlayer second = job.second == null ? null : power.online(job.second.player()); + Status check = validateEndpoint(job.first, first, job.currency); + if (check != SUCCESS) return new Resolved(check); + check = validateEndpoint(job.second, second, job.currency); + if (check != SUCCESS) return new Resolved(check); + var rule = power.rule(job.first == null ? null : job.first.player(), job.currency); + var limit = job.operation == Operation.CONVERT ? rule.conversionLimit() : rule.transferLimit(); + if (job.speed == null ? !rule.instant() : limit != null && job.speed.compareTo(limit) > 0) return new Resolved(POLICY); + if (job.operation == Operation.TRANSFER) { + boolean sameDimension = first.serverLevel() == second.serverLevel(); + if ((rule.sameDimension() || rule.maxDistance() > 0) && !sameDimension) return new Resolved(OUT_OF_RANGE); + if (rule.maxDistance() > 0 && first.distanceToSqr(second) > (double) rule.maxDistance() * rule.maxDistance()) + return new Resolved(OUT_OF_RANGE); + return new Resolved(SUCCESS); + } + ServerLevel level = power.level(job.dimension); + var pos = first == null ? job.position : first.blockPosition(); + if (level == null || !level.hasChunkAt(pos) || (job.nexusChunk != null && !level.hasChunkAt(job.nexusChunk))) + return new Resolved(UNAVAILABLE); + var node = LeyLinesApi.forLevel(level).power().quoteAt(pos.getX(), pos.getY(), pos.getZ()).orElse(null); + if (node == null) return new Resolved(job.nexus == null ? NO_NEXUS : MOVED); + if (job.nexus != null && !job.nexus.equals(node.nexus().id())) return new Resolved(MOVED); + return new Resolved(SUCCESS, level, pos, node); + } + + private record Tranche(Status status, MagicAmount moved, Optional receipt) { + Tranche(Status status) { this(status, MagicAmount.ZERO, Optional.empty()); } + } + + private Tranche tranche(Work job, Resolved resolved, MagicAmount amount, boolean allowPartial) { + var node = resolved.node; + var account = node == null ? null : node.account(); + var currency = job.currency; + var rate = power.rate(currency); + var source = job.first == null ? null : job.first.player(); + long funds; + long room; + switch (job.operation) { + case WITHDRAW -> { + funds = account.currency(currency); + room = power.room(source, currency); + } + case DEPOSIT -> { + funds = power.pool(source, currency).balance(); + var capacity = MagicAmount.of(account.capacity()); + room = capacity.compareTo(account.total()) <= 0 ? 0 + : floor(capacity.subtract(account.total()).multiply(rate), Long.MAX_VALUE - account.currency(currency)); + } + case TRANSFER -> { + funds = power.pool(source, currency).balance(); + room = power.room(job.second.player(), currency); + } + case CONVERT -> { + funds = floor(account.raw().multiply(rate), Long.MAX_VALUE); + room = Long.MAX_VALUE - account.currency(currency); + amount = amount.multiply(rate); + } + default -> throw new IllegalStateException("Unexpected energy operation " + job.operation); + } + long available = Math.min(funds, room); + boolean shortfall = amount.compareTo(MagicAmount.of(available)) > 0; + Status status = shortfall ? (funds <= room ? INSUFFICIENT_POWER : CAPACITY) : SUCCESS; + if (job.operation == Operation.CONVERT && shortfall && funds == Long.MAX_VALUE && room < Long.MAX_VALUE) status = OVERFLOW; + if (shortfall && (!allowPartial || available == 0)) return new Tranche(status); + long units = shortfall ? available : amount.wholeLongExact(); + var actual = job.operation == Operation.CONVERT ? MagicAmount.of(units).divide(rate) : MagicAmount.of(units); + var result = switch (job.operation) { + case WITHDRAW, CONVERT -> power.useNode(job.operation, source, resolved.level, resolved.pos, node, currency, actual, job.consumer); + case DEPOSIT -> power.deposit(source, GlobalPos.of(resolved.level.dimension(), resolved.pos), node, currency, units, job.consumer); + case TRANSFER -> power.movePlayers(source, job.second.player(), currency, units, job.consumer); + default -> throw new IllegalStateException("Unexpected energy operation " + job.operation); + }; + if (!result.succeeded()) return new Tranche(result.status()); + return new Tranche(status, actual, result.receipt()); + } + + static long floor(MagicAmount amount, long limit) { return amount.min(MagicAmount.of(limit)).floorLongExact(); } + + static MagicAmount earned(MagicAmount total, MagicAmount speed, long elapsed, MagicAmount unitsPerAmount) { + var output = speed.multiply(MagicAmount.of(elapsed)).min(total).multiply(unitsPerAmount); + return new MagicAmount(output.numerator().divide(output.denominator()), java.math.BigInteger.ONE).divide(unitsPerAmount); + } + + void tick(List online) { + long now = power.now(); + for (int i = 0; i < TICK_BUDGET && !due.isEmpty() && due.first().nextTick <= now; i++) { + var job = due.pollFirst(); + var result = resolve(job); + if (result.status != SUCCESS) { finish(job, result.status); continue; } + var rate = job.conversionRate == null ? MagicAmount.ONE : job.conversionRate; + // Advance by one scheduled tick even under backlog, so a slow scheduler cannot exceed a rate ceiling. + long elapsed = ++job.elapsed; + var entitled = earned(job.total, job.speed, elapsed, rate); + var remaining = entitled.subtract(job.completed); + if (!remaining.isZero()) { + var moved = tranche(job, result, remaining, true); + job.completed = job.completed.add(moved.moved); + if (moved.status != SUCCESS) { finish(job, moved.status); continue; } + } + if (job.speed.multiply(MagicAmount.of(elapsed)).compareTo(job.total) >= 0) { + finish(job, SUCCESS); + } else { + job.nextTick = now + 1; + due.add(job); + } + } + if (now % 20 == 0) for (var player : online) tryAuto(player); + } + + private void tryAuto(ServerPlayer player) { + if (!player.isAlive()) return; + var snapshot = power.inspect(player.getUUID()); + for (var entry : new TreeMap<>(snapshot.pools()).entrySet()) { + var currency = entry.getKey(); + var pool = entry.getValue(); + var speed = pool.settings().autoRate(); + long room = power.room(player.getUUID(), currency); + var key = new AutoKey(player.getUUID(), currency); + if (pool.active() && speed != null && room > 0 && !auto.containsKey(key)) { + submit(Operation.WITHDRAW, player, null, player.serverLevel(), player.blockPosition(), currency, + MagicAmount.of(room), Timing.rate(speed), AUTO, key); + } + } + } + + Optional read(UUID id) { + var running = active.get(id); + return Optional.ofNullable(running == null ? finished.get(id) : running.view(RUNNING)); + } + + boolean cancel(UUID id, Status status) { + var job = active.get(id); + if (job == null) return false; + finish(job, status); + return true; + } + + void stopFor(UUID player, Status status) { + for (var job : List.copyOf(active.values())) { + if ((job.first != null && job.first.player().equals(player)) + || (job.second != null && job.second.player().equals(player))) finish(job, status); + } + } + + void policyChanged() { + for (var job : List.copyOf(active.values())) finish(job, RELOADED); + } + + private void finish(Work job, Status status) { + active.remove(job.id); + due.remove(job); + if (job.automatic != null) auto.remove(job.automatic); + var report = job.view(status); + if (finished.size() == FINISHED_HISTORY) finished.remove(finished.keySet().iterator().next()); + finished.put(job.id, report); + power.jobFinished(report); + } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/server/EnergyOrb.java b/src/main/java/zone/moddev/mc/skysleylines/server/EnergyOrb.java new file mode 100644 index 0000000..6e8f11f --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/server/EnergyOrb.java @@ -0,0 +1,44 @@ +package zone.moddev.mc.skysleylines.server; + +import java.util.UUID; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import zone.moddev.mc.skysleylines.SkysLeyLines; + +/** A tracked view of a drop. Neither the entity nor the spawn packet contains a spendable balance. */ +public final class EnergyOrb extends Entity { + private UUID drop; + private long nextPickup; + + public EnergyOrb(EntityType type, Level level) { super(type, level); } + + EnergyOrb(ServerLevel level, UUID drop, Vec3 position) { + this(SkysLeyLines.ENERGY_ORB.get(), level); + this.drop = drop; + setPos(position); + } + + @Override protected void defineSynchedData(SynchedEntityData.Builder builder) {} + @Override protected void readAdditionalSaveData(CompoundTag tag) {} + @Override protected void addAdditionalSaveData(CompoundTag tag) {} + + @Override public void tick() { + super.tick(); + if (!(level() instanceof ServerLevel level)) return; + var energy = LeyServerContext.playerEnergy(level.getServer()); + if (!energy.validProjection(drop, this)) { discard(); return; } + if (level.getGameTime() < nextPickup) return; + nextPickup = level.getGameTime() + 5; + for (var player : level.getEntitiesOfClass(ServerPlayer.class, getBoundingBox().inflate(1.0), + player -> player.isAlive() && !player.isSpectator())) { + energy.pickup(drop, player); + if (isRemoved()) break; + } + } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/server/LeyCommands.java b/src/main/java/zone/moddev/mc/skysleylines/server/LeyCommands.java index d97961e..6be78c7 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/server/LeyCommands.java +++ b/src/main/java/zone/moddev/mc/skysleylines/server/LeyCommands.java @@ -42,6 +42,7 @@ public static void register(RegisterCommandsEvent event) { .then(Commands.argument("pos", BlockPosArgument.blockPos()) .executes(c -> recover(c.getSource(), ResourceLocationArgument.getId(c, "currency"), LongArgumentType.getLong(c, "units"), BlockPosArgument.getBlockPos(c, "pos")))))))) + .then(NexusPinCommands.root()) .then(Commands.literal("vision").executes(c -> vision(c.getSource(), null)) .then(Commands.literal("on").executes(c -> vision(c.getSource(), true))) .then(Commands.literal("off").executes(c -> vision(c.getSource(), false)))) @@ -51,8 +52,8 @@ public static void register(RegisterCommandsEvent event) { .then(Commands.argument("revision", LongArgumentType.longArg(0)).executes(c -> apply(c.getSource(), LongArgumentType.getLong(c, "revision")))))) .then(Commands.literal("profile").executes(c -> { var p = LeyLinesApi.forLevel(c.getSource().getLevel()).profile(); - int effective = LeyServerContext.world(c.getSource().getServer()).ledger().rechargeTicks(); - message(c.getSource(), "profile", LeyProfile.LAYOUT_VERSION, p, effective); + var ledger = LeyServerContext.world(c.getSource().getServer()).ledger(); + message(c.getSource(), "profile", LeyProfile.LAYOUT_VERSION, p, ledger.rechargeTicks(), ledger.capacityMultiplier()); return 1; }))); } @@ -90,10 +91,13 @@ private static int nearest(CommandSourceStack source) { Nexus node = found.get(); int x = (int) Math.floor(node.x()), z = (int) Math.floor(node.z()); LeyColumn column = context.field().sampleColumn(x, z); - String location = column.active() + String location = node.authoredY().isPresent() + ? String.format(Locale.ROOT, "%.2f %d %.2f", node.x(), node.authoredY().getAsInt(), node.z()) + : column.active() ? String.format(Locale.ROOT, "%.2f %d %.2f", node.x(), column.naturalSurfaceY(), node.z()) : String.format(Locale.ROOT, "%.2f %.2f", node.x(), node.z()); - var height = command(column.active() ? "locate.ground" : "locate.no_ground"); + var height = node.authoredY().isPresent() ? Component.literal("authored anchor") + : command(column.active() ? "locate.ground" : "locate.no_ground"); long distance = Math.round(Math.hypot(node.x() - source.getPosition().x, node.z() - source.getPosition().z)); var coordinates = Component.literal(location).withStyle(style -> style.withColor(ChatFormatting.AQUA) .withClickEvent(new ClickEvent(ClickEvent.Action.COPY_TO_CLIPBOARD, location)) diff --git a/src/main/java/zone/moddev/mc/skysleylines/server/LeyServerContext.java b/src/main/java/zone/moddev/mc/skysleylines/server/LeyServerContext.java index ee3c87d..fa47a4c 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/server/LeyServerContext.java +++ b/src/main/java/zone/moddev/mc/skysleylines/server/LeyServerContext.java @@ -4,6 +4,9 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.levelgen.RandomState; +import net.neoforged.neoforge.event.level.LevelEvent; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.resources.ResourceLocation; import net.neoforged.neoforge.common.NeoForge; @@ -16,7 +19,39 @@ public final class LeyServerContext { private static final Map CONTEXTS = new IdentityHashMap<>(); private static final Map WORLDS = new IdentityHashMap<>(); + private static final Map PLAYER_ENERGY = new IdentityHashMap<>(); + private record WorldgenBinding(ServerLevel level, Optional sampler) {} + private static volatile Map worldgen = Map.of(); + private LeyServerContext() {} + + public static Optional worldgenSampler(RandomState state) { + var binding = worldgen.get(Objects.requireNonNull(state)); + return binding == null ? Optional.empty() : binding.sampler; + } + + public static void levelLoad(LevelEvent.Load event) { + if (!(event.getLevel() instanceof ServerLevel level) || !level.dimension().equals(Level.OVERWORLD)) return; + var sampler = forLevel(level).field(); + synchronized (LeyServerContext.class) { + var next = new IdentityHashMap<>(worldgen); + next.put(level.getChunkSource().randomState(), new WorldgenBinding(level, Optional.of(sampler))); + worldgen = Collections.unmodifiableMap(next); + } + } + + public static void levelUnload(LevelEvent.Unload event) { + if (event.getLevel() instanceof ServerLevel level) { + clearWorldgen(binding -> binding.level == level); + CONTEXTS.remove(level); + } + } + + private static synchronized void clearWorldgen(java.util.function.Predicate match) { + var next = new IdentityHashMap<>(worldgen); + next.values().removeIf(match); + worldgen = Collections.unmodifiableMap(next); + } private static void serverThread(MinecraftServer server) { if (!server.isSameThread()) throw new IllegalStateException("Ley context/reserve operations require the server thread"); } @@ -34,7 +69,7 @@ private static LeyLinesApi.Context create(ServerLevel level, LeyWorldData data) LeyNetwork field = new LeyNetwork(level.getSeed(), dimension, data.profile(), (x, z) -> { int free = generator.getBaseHeight(x, z, Heightmap.Types.OCEAN_FLOOR_WG, heights, randomState); return free <= heights.getMinBuildHeight() ? OptionalInt.empty() : OptionalInt.of(free - 1); - }, dimension.equals("minecraft:overworld")); + }, dimension.equals("minecraft:overworld"), data.activePins()); NexusPowerService power = new NexusPowerService() { @Override public Optional quoteAt(int x, int y, int z) { serverThread(server); @@ -51,7 +86,8 @@ private static LeyLinesApi.Context create(ServerLevel level, LeyWorldData data) Optional quote = quoteAt(request.x(), request.y(), request.z()); if (quote.isEmpty()) return PowerResult.failed(PowerResult.Status.NO_NEXUS); NexusSnapshot node = quote.get(); - return data.ledger().transact(node.nexus().id(), node.capacity(), server.overworld().getGameTime(), request, new NexusLedger.Hooks() { + return data.ledger().transact(node.nexus().id(), node.nexus().capacity(data.profile()), + server.overworld().getGameTime(), request, new NexusLedger.Hooks() { @Override public boolean before(PowerResult.Receipt proposed) { return !NeoForge.EVENT_BUS.post(new NexusTransactionEvent.Pre(level, proposed)).isCanceled(); } @@ -65,7 +101,14 @@ private static LeyLinesApi.Context create(ServerLevel level, LeyWorldData data) }; return new LeyLinesApi.Context(data.profile(), field, power); } + public static PlayerEnergy playerEnergy(MinecraftServer server) { + serverThread(server); + return PLAYER_ENERGY.computeIfAbsent(server, key -> new PlayerEnergy(key, world(key))); + } + public static void clear(MinecraftServer server) { + clearWorldgen(binding -> binding.level.getServer() == server); + PLAYER_ENERGY.remove(server); LeyCommands.clear(server); VisionUpdates.clear(server); CONTEXTS.keySet().removeIf(level -> level.getServer() == server); diff --git a/src/main/java/zone/moddev/mc/skysleylines/server/LeyWorldData.java b/src/main/java/zone/moddev/mc/skysleylines/server/LeyWorldData.java index 84f89bf..5a0a296 100644 --- a/src/main/java/zone/moddev/mc/skysleylines/server/LeyWorldData.java +++ b/src/main/java/zone/moddev/mc/skysleylines/server/LeyWorldData.java @@ -22,22 +22,29 @@ public final class LeyWorldData extends SavedData { public static final String NAME = "skysleylines"; private static final int BASE_SCHEMA = 2; - public static final int SCHEMA = 3; + private static final int RECHARGE_SCHEMA = 3; + private static final int PLAYER_SCHEMA = 4; + private static final int PIN_SCHEMA = 5; + public static final int SCHEMA = 6; + final NexusPins pins = new NexusPins(); + private boolean hasPinHistory; + final PlayerEnergyData playerEnergy = new PlayerEnergyData(); + private boolean hasPlayerHistory; private final LeyProfile profile; private final NexusLedger ledger; private final LongSupplier clock; private Map configuredRates = Map.of(); private Path storageDirectory; private boolean hasRechargeHistory; + private boolean hasCapacityHistory; public LeyWorldData(LeyProfile profile, LongSupplier clock) { - this(profile, clock, profile.rechargeTicks(), false); + this(profile, clock, profile.rechargeTicks(), NexusLedger.CAPACITY_SCALE_ONE); } - private LeyWorldData(LeyProfile profile, LongSupplier clock, int effectiveRechargeTicks, boolean hasRechargeHistory) { + private LeyWorldData(LeyProfile profile, LongSupplier clock, int effectiveRechargeTicks, int capacityScale) { this.profile = profile; - this.ledger = new NexusLedger(effectiveRechargeTicks); + this.ledger = new NexusLedger(effectiveRechargeTicks, capacityScale); this.clock = clock; - this.hasRechargeHistory = hasRechargeHistory; } public LeyProfile profile() { return profile; } public NexusLedger ledger() { return ledger; } @@ -46,6 +53,7 @@ public static LeyWorldData get(MinecraftServer server) { LongSupplier clock = () -> server.overworld().getGameTime(); Path path = server.getWorldPath(LevelResource.ROOT).resolve("data").resolve(NAME + ".dat"); int override = LeyConfig.rechargeOverride(); + int capacityScale = LeyConfig.capacityScale(); var configured = LeyLinesApi.paradigms().configuredRates(); LeyWorldData data; boolean existing = !Files.notExists(path); @@ -67,12 +75,14 @@ public static LeyWorldData get(MinecraftServer server) { data.setDirty(); } data.storageDirectory = path.getParent(); - data.applyRechargeOverride(override, () -> { + if (data.pins.activateStaged()) data.setDirty(); + String backupReason = capacityScale != data.ledger.capacityScale() ? "before-capacity" : "before-recharge"; + data.applyPoolSettings(override, capacityScale, () -> { if (existing) { try { - copyBackup(path, "before-recharge"); + copyBackup(path, backupReason); } catch (IOException failure) { - throw new IllegalStateException("Recharge backup failed; ley data has not been replaced", failure); + throw new IllegalStateException("Nexus pool settings backup failed; ley data has not been replaced", failure); } } }); @@ -85,10 +95,48 @@ private static void copyBackup(Path path, String reason) throws IOException { Files.copy(path, path.resolveSibling(NAME + "-" + reason + "-" + UUID.randomUUID() + ".dat.bak")); } + List activePins() { return pins.active(); } + + void stagePin(NexusPins.Pin pin) { + if (ledger.mutationInProgress()) throw new IllegalStateException("Nested ledger mutation"); + if (!hasPinHistory) { + backupCurrent("before-pins"); + hasPinHistory = true; + } + pins.put(pin); + setDirty(); + } + + private void backupCurrent(String reason) { + if (storageDirectory == null) throw new IllegalStateException("No world backup directory"); + try { + Files.createDirectories(storageDirectory); + Path backup = Files.createTempFile(storageDirectory, NAME + "-" + reason + "-", ".dat.bak"); + CompoundTag root = new CompoundTag(); + root.put("data", encode(new CompoundTag())); + NbtIo.writeCompressed(root, backup); + } catch (IOException failure) { + throw new IllegalStateException("Ley ledger backup failed (" + reason + "); ledger unchanged", failure); + } + } + + void preparePlayerSave() { + if (hasPlayerHistory || hasPinHistory) return; + backupCurrent("before-player-energy"); + hasPlayerHistory = true; + } + public void applyRechargeOverride(int override, Runnable backup) { + applyPoolSettings(override, ledger.capacityScale(), backup); + } + + public void applyPoolSettings(int override, int capacityScale, Runnable backup) { int target = override == 0 ? profile.rechargeTicks() : override; - if (ledger.changeRechargeTicks(target, Math.max(0, clock.getAsLong()), backup)) { - hasRechargeHistory = true; + boolean rechargeChanged = target != ledger.rechargeTicks(); + boolean capacityChanged = capacityScale != ledger.capacityScale(); + if (ledger.changeLimits(target, capacityScale, Math.max(0, clock.getAsLong()), backup)) { + hasRechargeHistory |= rechargeChanged; + hasCapacityHistory |= capacityChanged; setDirty(); } } @@ -100,7 +148,20 @@ public void configureCurrencies(Map rates) { if (!rate.equals(ledger.rates().get(id))) LogUtils.getLogger().warn("Ley currency {} retains saved rate {}; configured {} requires explicit migration", id, ledger.rates().get(id), rate); }); } - public RateMigration previewMigration() { return ledger.previewMigration(configuredRates, Math.max(0, clock.getAsLong())); } + public RateMigration previewMigration() { + var entries = pins.list().isEmpty() ? Map.of() : ledger.entries(); + for (var pin : pins.list()) { + if (pin.state() != NexusPins.State.RETIRED) continue; + var balance = entries.get(pin.nexus().id()); + if (balance == null) continue; + for (var currency : balance.currencies().keySet()) { + var configured = configuredRates.get(currency); + if (configured != null && !configured.equals(ledger.savedRate(currency))) + throw new IllegalStateException("Recover " + currency + " from retired pin " + pin.id() + " before migrating its rate"); + } + } + return ledger.previewMigration(configuredRates, Math.max(0, clock.getAsLong())); + } public boolean applyMigration(RateMigration preview) { if (!preview.newRates().equals(previewMigration().newRates())) return false; return ledger.applyMigration(preview, Math.max(0, clock.getAsLong()), new NexusLedger.MigrationHooks() { @@ -139,11 +200,18 @@ public static LeyWorldData load(CompoundTag tag, LongSupplier clock) { LeyProfile profile = new LeyProfile(p.getInt("spacing"), p.getInt("lineWidth"), p.getInt("nexusRadius"), p.getInt("verticalReach"), p.getInt("capacity"), p.getInt("rechargeTicks")); int effectiveRechargeTicks = profile.rechargeTicks(); - if (schema == SCHEMA) { + if (schema >= RECHARGE_SCHEMA) { require(tag, "effectiveRechargeTicks", Tag.TAG_INT); effectiveRechargeTicks = tag.getInt("effectiveRechargeTicks"); } - LeyWorldData data = new LeyWorldData(profile, clock, effectiveRechargeTicks, schema == SCHEMA); + int capacityScale = NexusLedger.CAPACITY_SCALE_ONE; + if (schema == SCHEMA) { + require(tag, "effectiveCapacityScale", Tag.TAG_INT); + capacityScale = tag.getInt("effectiveCapacityScale"); + } + LeyWorldData data = new LeyWorldData(profile, clock, effectiveRechargeTicks, capacityScale); + data.hasRechargeHistory = schema >= RECHARGE_SCHEMA; + data.hasCapacityHistory = schema == SCHEMA; if (schema >= BASE_SCHEMA) { require(tag, "revision", Tag.TAG_LONG); data.ledger.restoreRevision(tag.getLong("revision")); @@ -153,13 +221,28 @@ public static LeyWorldData load(CompoundTag tag, LongSupplier clock) { data.ledger.restoreRate(identifier(rate.getString("id")), amount(rate, "rate")); } } + if (schema == PIN_SCHEMA || (schema == SCHEMA && tag.contains("pins"))) { + data.hasPinHistory = true; + data.pins.read(tag); + } ListTag rows = compounds(tag, "balances"); for (int i = 0; i < rows.size(); i++) { CompoundTag row = rows.getCompound(i); require(row, "dimension", Tag.TAG_STRING); - for (String key : new String[]{"ax", "az", "bx", "bz", "capacity"}) require(row, key, Tag.TAG_INT); + require(row, "capacity", Tag.TAG_INT); require(row, "tick", Tag.TAG_LONG); - NexusId id = new NexusId(row.getString("dimension"), row.getInt("ax"), row.getInt("az"), row.getInt("bx"), row.getInt("bz")); + NexusId id; + if (schema >= PIN_SCHEMA && row.contains("pin")) { + require(row, "pin", Tag.TAG_STRING); + var pin = data.pins.get(identifier(row.getString("pin"))); + if (pin == null || pin.state() == NexusPins.State.STAGED_ADD + || pin.nexus().capacity(profile) != row.getInt("capacity")) + throw new IllegalArgumentException("Invalid pinned account: " + row.getString("pin")); + id = NexusId.pinned(row.getString("dimension"), pin.id()); + } else { + for (String key : new String[]{"ax", "az", "bx", "bz"}) require(row, key, Tag.TAG_INT); + id = new NexusId(row.getString("dimension"), row.getInt("ax"), row.getInt("az"), row.getInt("bx"), row.getInt("bz")); + } if (!id.dimension().equals("minecraft:overworld")) throw new IllegalArgumentException("Inactive dimension balance"); MagicAmount raw; var currencies = new TreeMap(); @@ -179,23 +262,29 @@ public static LeyWorldData load(CompoundTag tag, LongSupplier clock) { if (units <= 0 || !data.ledger.rates().containsKey(key) || currencies.putIfAbsent(key, units) != null) throw new IllegalArgumentException("Invalid/duplicate currency balance"); } } - data.ledger.restore(id, new NexusLedger.Balance(row.getInt("capacity"), raw, currencies, row.getLong("tick"))); + data.ledger.restore(id, new NexusLedger.Balance(row.getInt("capacity"), raw, currencies, row.getLong("tick")), + data.hasCapacityHistory); + } + if (schema == PLAYER_SCHEMA || data.hasPinHistory || (schema == SCHEMA && tag.contains("playerEnergy"))) { + data.hasPlayerHistory = true; + require(tag, "playerEnergy", Tag.TAG_COMPOUND); + data.playerEnergy.read(tag.getCompound("playerEnergy"), data.ledger); } if (schema == 1) data.setDirty(); return data; } - private static ListTag compounds(CompoundTag tag, String key) { + static ListTag compounds(CompoundTag tag, String key) { require(tag, key, Tag.TAG_LIST); ListTag list = (ListTag) tag.get(key); if (!list.isEmpty() && list.getElementType() != Tag.TAG_COMPOUND) throw new IllegalArgumentException("Invalid " + key + " list"); return list; } - private static ResourceLocation identifier(String value) { + static ResourceLocation identifier(String value) { ResourceLocation id = ResourceLocation.tryParse(value); if (!value.contains(":") || id == null) throw new IllegalArgumentException("Invalid currency identifier"); return id; } - private static MagicAmount amount(CompoundTag tag, String key) { + static MagicAmount amount(CompoundTag tag, String key) { require(tag, key, Tag.TAG_COMPOUND); CompoundTag value = tag.getCompound(key); require(value, "numerator", Tag.TAG_STRING); require(value, "denominator", Tag.TAG_STRING); @@ -203,12 +292,12 @@ private static MagicAmount amount(CompoundTag tag, String key) { if (!n.matches("0|[1-9][0-9]*") || !d.matches("[1-9][0-9]*")) throw new IllegalArgumentException("Invalid exact amount"); return new MagicAmount(new BigInteger(n), new BigInteger(d)); } - private static void putAmount(CompoundTag tag, String key, MagicAmount amount) { + static void putAmount(CompoundTag tag, String key, MagicAmount amount) { CompoundTag value = new CompoundTag(); value.putString("numerator", amount.numerator().toString()); value.putString("denominator", amount.denominator().toString()); tag.put(key, value); } - private static void require(CompoundTag tag, String key, int type) { + static void require(CompoundTag tag, String key, int type) { if (!tag.contains(key, type)) throw new IllegalArgumentException("Missing/invalid ley field: " + key); } @Override public CompoundTag save(CompoundTag tag, HolderLookup.Provider lookup) { @@ -216,8 +305,13 @@ private static void require(CompoundTag tag, String key, int type) { return encode(tag); } private CompoundTag encode(CompoundTag tag) { - tag.putInt("schema", hasRechargeHistory ? SCHEMA : BASE_SCHEMA); - if (hasRechargeHistory) tag.putInt("effectiveRechargeTicks", ledger.rechargeTicks()); + tag.putInt("schema", hasCapacityHistory ? SCHEMA : hasPinHistory ? PIN_SCHEMA + : hasPlayerHistory ? PLAYER_SCHEMA : hasRechargeHistory ? RECHARGE_SCHEMA : BASE_SCHEMA); + if (hasCapacityHistory) tag.putInt("effectiveCapacityScale", ledger.capacityScale()); + if (hasRechargeHistory || hasPlayerHistory || hasPinHistory || hasCapacityHistory) + tag.putInt("effectiveRechargeTicks", ledger.rechargeTicks()); + if (hasPlayerHistory || hasPinHistory) tag.put("playerEnergy", playerEnergy.write()); + if (hasPinHistory) tag.put("pins", pins.write()); tag.putInt("layoutVersion", LeyProfile.LAYOUT_VERSION); tag.putString("dimension", "minecraft:overworld"); CompoundTag p = new CompoundTag(); @@ -235,8 +329,14 @@ private CompoundTag encode(CompoundTag tag) { for (Map.Entry entry : ledger.entries().entrySet()) { NexusId id = entry.getKey(); NexusLedger.Balance balance = entry.getValue(); CompoundTag row = new CompoundTag(); - row.putString("dimension", id.dimension()); row.putInt("ax", id.ax()); row.putInt("az", id.az()); - row.putInt("bx", id.bx()); row.putInt("bz", id.bz()); row.putInt("capacity", balance.capacity()); + row.putString("dimension", id.dimension()); + if (id.pinId().isPresent()) { + row.putString("pin", id.pinId().get().toString()); + } else { + row.putInt("ax", id.ax()); row.putInt("az", id.az()); + row.putInt("bx", id.bx()); row.putInt("bz", id.bz()); + } + row.putInt("capacity", balance.capacity()); putAmount(row, "raw", balance.raw()); row.putLong("tick", balance.tick()); ListTag currencies = new ListTag(); balance.currencies().forEach((currency, units) -> { diff --git a/src/main/java/zone/moddev/mc/skysleylines/server/NexusPinCommands.java b/src/main/java/zone/moddev/mc/skysleylines/server/NexusPinCommands.java new file mode 100644 index 0000000..8b38238 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/server/NexusPinCommands.java @@ -0,0 +1,160 @@ +package zone.moddev.mc.skysleylines.server; + +import com.mojang.brigadier.arguments.DoubleArgumentType; +import com.mojang.brigadier.arguments.LongArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.logging.LogUtils; +import java.util.Locale; +import java.util.Optional; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.ResourceLocationArgument; +import net.minecraft.commands.arguments.coordinates.BlockPosArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.Level; +import net.neoforged.neoforge.common.NeoForge; +import zone.moddev.mc.skysleylines.api.*; +import zone.moddev.mc.skysleylines.core.LeyNetwork; +import zone.moddev.mc.skysleylines.core.NexusLedger; +import static zone.moddev.mc.skysleylines.server.NexusPins.State.*; + +final class NexusPinCommands { + private static final ResourceLocation RECOVERY = ResourceLocation.parse("skysleylines:pin_recovery"); + private NexusPinCommands() {} + + static LiteralArgumentBuilder root() { + return Commands.literal("nexus").then(Commands.literal("pin").requires(source -> source.hasPermission(4)) + .then(place("preview", false)).then(place("add", true)) + .then(Commands.literal("list").executes(NexusPinCommands::list)) + .then(Commands.literal("remove").then(Commands.argument("id", ResourceLocationArgument.id()) + .executes(NexusPinCommands::remove))) + .then(Commands.literal("recover").then(Commands.argument("id", ResourceLocationArgument.id()) + .then(Commands.argument("currency", ResourceLocationArgument.id()) + .then(Commands.argument("units", LongArgumentType.longArg(1)) + .then(Commands.argument("pos", BlockPosArgument.blockPos()).executes(NexusPinCommands::recover))))))); + } + + private static LiteralArgumentBuilder place(String verb, boolean commit) { + return Commands.literal(verb).then(Commands.argument("id", ResourceLocationArgument.id()) + .then(Commands.argument("pos", BlockPosArgument.blockPos()) + .then(Commands.argument("strength", DoubleArgumentType.doubleArg(Double.MIN_VALUE, 1)) + .executes(context -> place(context, commit))))); + } + + private static ResourceLocation id(CommandContext context) throws CommandSyntaxException { + boolean explicit = context.getNodes().stream().anyMatch(node -> node.getNode().getName().equals("id") + && node.getRange().get(context.getInput()).contains(":")); + if (!explicit) throw new IllegalArgumentException("Use a namespaced pin ID such as pack:dragon_roost"); + return ResourceLocationArgument.getId(context, "id"); + } + + private static int place(CommandContext context, boolean commit) throws CommandSyntaxException { + var source = context.getSource(); + var level = source.getLevel(); + if (!level.dimension().equals(Level.OVERWORLD)) return fail(source, "Nexus pins belong to the Overworld"); + try { + var pos = BlockPosArgument.getBlockPos(context, "pos"); + var pin = new NexusPins.Pin(id(context), pos, DoubleArgumentType.getDouble(context, "strength"), STAGED_ADD); + var data = LeyServerContext.world(source.getServer()); + data.pins.checkAdd(pin); + var field = (LeyNetwork) LeyLinesApi.forLevel(level).field(); + if (level.isOutsideBuildHeight(pos) || !field.sampleColumn(pos.getX(), pos.getZ()).active()) + return fail(source, "Choose a position within build bounds over natural terrain"); + var links = field.connections(pin.nexus()); + var overlaps = new java.util.TreeSet(); + field.overlappingNexuses(pin.nexus()).forEach(nexus -> overlaps.add(nexus.id())); + double radius = 2.0 * data.profile().nexusRadius(); + for (var other : data.pins.list()) { + if (other.state() == STAGED_ADD && other.nexus().distanceSquared(pos.getX(), pos.getZ()) < radius * radius) + overlaps.add(other.nexus().id()); + } + if (commit) data.stagePin(pin); + tell(source, (commit ? "Staged for next restart: " : "Preview: ") + pin.id() + + " at " + pos.toShortString() + ", strength " + pin.strength() + + ", capacity " + data.ledger().scaledCapacity(pin.nexus().capacity(data.profile()))); + if (!overlaps.isEmpty()) tell(source, "Overlapping fields: " + overlaps); + if (links.isEmpty()) tell(source, "Isolated pin. No seeded line joins within " + LeyNetwork.CONNECTOR_REACH + " blocks."); + for (var link : links) tell(source, String.format(Locale.ROOT, "Join at %.2f %d %.2f", link.x(), link.y(), link.z())); + return 1; + } catch (IllegalArgumentException | IllegalStateException failure) { + return fail(source, failure.getMessage()); + } + } + + private static int list(CommandContext context) { + var source = context.getSource(); + var data = LeyServerContext.world(source.getServer()); + var pins = data.pins.list(); + if (pins.isEmpty()) tell(source, "No nexus pins"); + var balances = data.ledger().entries(); + for (var pin : pins) { + var balance = balances.get(pin.nexus().id()); + tell(source, pin.id() + " " + pin.state() + " at " + pin.position().toShortString() + + (balance == null || balance.currencies().isEmpty() ? "" : " holds " + balance.currencies())); + } + return pins.size(); + } + + private static int remove(CommandContext context) throws CommandSyntaxException { + var source = context.getSource(); + try { + var data = LeyServerContext.world(source.getServer()); + var pin = data.pins.get(id(context)); + if (pin == null) return fail(source, "Unknown nexus pin"); + if (pin.state() == RETIRED || pin.state() == STAGED_REMOVE) return fail(source, "Pin is already retired or staged for removal"); + boolean pending = pin.state() == STAGED_ADD; + data.stagePin(pin.state(pending ? RETIRED : STAGED_REMOVE)); + tell(source, pending ? "Cancelled staged pin " + pin.id() : "Staged retirement for next restart: " + pin.id()); + return 1; + } catch (IllegalArgumentException | IllegalStateException failure) { + return fail(source, failure.getMessage()); + } + } + + private static int recover(CommandContext context) throws CommandSyntaxException { + var source = context.getSource(); + var level = source.getLevel(); + if (!level.dimension().equals(Level.OVERWORLD)) return fail(source, "Recovery requires an Overworld destination"); + try { + var data = LeyServerContext.world(source.getServer()); + var pin = data.pins.get(id(context)); + if (pin == null || pin.state() != RETIRED) return fail(source, "Recover from a retired pin"); + var pos = BlockPosArgument.getBlockPos(context, "pos"); + var destination = LeyLinesApi.forLevel(level).power().quoteAt(pos.getX(), pos.getY(), pos.getZ()).orElse(null); + if (destination == null) return fail(source, "No active nexus at the destination"); + var currency = ResourceLocationArgument.getId(context, "currency"); + long units = LongArgumentType.getLong(context, "units"); + var consumer = new PowerRequest.Consumer(RECOVERY, Optional.ofNullable(source.getEntity()).map(entity -> entity.getUUID())); + var status = data.ledger().recover(pin.nexus().id(), pin.position(), destination.nexus().id(), pos, + destination.nexus().capacity(data.profile()), level.getGameTime(), currency, units, consumer, new NexusLedger.RecoveryHooks() { + @Override public boolean before(NexusRecoveryEvent.Receipt receipt) { + return !NeoForge.EVENT_BUS.post(new NexusRecoveryEvent.Pre(level, receipt)).isCanceled(); + } + @Override public void after(NexusRecoveryEvent.Receipt receipt) { + data.setDirty(); + NeoForge.EVENT_BUS.post(new NexusRecoveryEvent.Post(level, receipt)); + } + @Override public void failure(RuntimeException failure) { + LogUtils.getLogger().error("Pin recovery listener failed", failure); + } + }); + if (status != NexusLedger.RecoveryStatus.SUCCESS) return fail(source, "Pin recovery failed: " + status); + tell(source, "Recovered " + units + " " + currency + " from " + pin.id() + " to " + destination.nexus().id()); + return 1; + } catch (IllegalArgumentException | IllegalStateException failure) { + return fail(source, failure.getMessage()); + } + } + + private static void tell(CommandSourceStack source, String message) { + source.sendSuccess(() -> Component.literal(message), false); + } + + private static int fail(CommandSourceStack source, String message) { + source.sendFailure(Component.literal(message)); + return 0; + } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/server/NexusPins.java b/src/main/java/zone/moddev/mc/skysleylines/server/NexusPins.java new file mode 100644 index 0000000..5d18e3b --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/server/NexusPins.java @@ -0,0 +1,104 @@ +package zone.moddev.mc.skysleylines.server; + +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; +import java.util.TreeMap; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.ResourceLocation; +import zone.moddev.mc.skysleylines.api.Nexus; +import zone.moddev.mc.skysleylines.api.NexusId; + +final class NexusPins { + static final String DIMENSION = "minecraft:overworld"; + static final int LIMIT = 256; + enum State { STAGED_ADD, ACTIVE, STAGED_REMOVE, RETIRED } + + record Pin(ResourceLocation id, BlockPos position, double strength, State state) { + Pin { + if (id.getNamespace().isEmpty() || id.getPath().isEmpty() || id.toString().length() > 128 + || Math.abs((long) position.getX()) >= 30_000_000 + || Math.abs((long) position.getZ()) >= 30_000_000 || Math.abs((long) position.getY()) > 4096 + || !(strength > 0 && strength <= 1)) throw new IllegalArgumentException("Invalid nexus pin"); + position = position.immutable(); + } + + Nexus nexus() { + return new Nexus(NexusId.pinned(DIMENSION, id), position.getX(), position.getZ(), strength, + OptionalInt.of(position.getY())); + } + + Pin state(State next) { return new Pin(id, position, strength, next); } + boolean occupiesAnchor() { return state != State.RETIRED; } + } + + private final Map entries = new TreeMap<>(); + + Pin get(ResourceLocation id) { return entries.get(id); } + List list() { return List.copyOf(entries.values()); } + + List active() { + return entries.values().stream().filter(pin -> pin.state == State.ACTIVE || pin.state == State.STAGED_REMOVE) + .map(Pin::nexus).toList(); + } + + void checkAdd(Pin pin) { + if (entries.containsKey(pin.id)) throw new IllegalArgumentException("Pin ID is already reserved: " + pin.id); + if (entries.values().stream().filter(Pin::occupiesAnchor).count() >= LIMIT) + throw new IllegalArgumentException("This world already has " + LIMIT + " active or staged pins"); + if (entries.values().stream().anyMatch(other -> other.occupiesAnchor() && other.position.equals(pin.position))) + throw new IllegalArgumentException("A pin already occupies that block"); + } + + void put(Pin pin) { entries.put(pin.id, pin); } + + boolean activateStaged() { + boolean changed = false; + for (var entry : entries.entrySet()) { + var pin = entry.getValue(); + State next = switch (pin.state) { + case STAGED_ADD -> State.ACTIVE; + case STAGED_REMOVE -> State.RETIRED; + default -> pin.state; + }; + if (next != pin.state) { + entry.setValue(pin.state(next)); + changed = true; + } + } + return changed; + } + + void read(CompoundTag root) { + for (Tag entry : LeyWorldData.compounds(root, "pins")) { + var row = (CompoundTag) entry; + LeyWorldData.require(row, "id", Tag.TAG_STRING); + LeyWorldData.require(row, "state", Tag.TAG_STRING); + LeyWorldData.require(row, "strength", Tag.TAG_DOUBLE); + for (String axis : List.of("x", "y", "z")) LeyWorldData.require(row, axis, Tag.TAG_INT); + var pin = new Pin(LeyWorldData.identifier(row.getString("id")), + new BlockPos(row.getInt("x"), row.getInt("y"), row.getInt("z")), + row.getDouble("strength"), State.valueOf(row.getString("state"))); + if (pin.occupiesAnchor()) checkAdd(pin); + if (entries.putIfAbsent(pin.id, pin) != null) throw new IllegalArgumentException("Duplicate pin: " + pin.id); + } + } + + ListTag write() { + var rows = new ListTag(); + entries.values().forEach(pin -> { + var row = new CompoundTag(); + row.putString("id", pin.id.toString()); + row.putString("state", pin.state.name()); + row.putInt("x", pin.position.getX()); + row.putInt("y", pin.position.getY()); + row.putInt("z", pin.position.getZ()); + row.putDouble("strength", pin.strength); + rows.add(row); + }); + return rows; + } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergy.java b/src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergy.java new file mode 100644 index 0000000..be368a9 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergy.java @@ -0,0 +1,406 @@ +package zone.moddev.mc.skysleylines.server; + +import com.mojang.logging.LogUtils; +import java.io.IOException; +import java.util.*; +import java.util.function.Supplier; +import net.minecraft.core.BlockPos; +import net.minecraft.core.GlobalPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.GameRules; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.entity.living.LivingDeathEvent; +import zone.moddev.mc.skysleylines.api.*; +import zone.moddev.mc.skysleylines.core.LeySaveLimits; +import zone.moddev.mc.skysleylines.core.NexusLedger; +import zone.moddev.mc.skysleylines.server.PlayerEnergyData.Drop; +import zone.moddev.mc.skysleylines.server.PlayerEnergyData.StoredPool; +import static zone.moddev.mc.skysleylines.api.PlayerPowerService.Status.*; + +public final class PlayerEnergy implements PlayerPowerService { + private static final PowerRequest.Consumer DEATH = system("death"); + private static final PowerRequest.Consumer PICKUP = system("pickup"); + static final Settings ABSENT = new Settings(false, 0, null); + private final MinecraftServer server; + private final LeyWorldData world; + private final PlayerEnergyData data; + private final NexusLedger ledger; + private final EnergyJobs jobs; + private final Map deaths = new HashMap<>(); + private final Map orbs = new HashMap<>(); + private final NavigableSet expirations; + private PlayerEnergyPolicy policy; + private UUID lastProjection; + + PlayerEnergy(MinecraftServer server, LeyWorldData world) { + this.server = server; + this.world = world; + data = world.playerEnergy; + ledger = world.ledger(); + jobs = new EnergyJobs(this); + expirations = new TreeSet<>(Comparator.comparingLong(id -> data.drops.get(id).expires()).thenComparing(Comparator.naturalOrder())); + expirations.addAll(data.drops.keySet()); + try { policy = PlayerEnergyPolicy.read(server); } + catch (IOException | RuntimeException failure) { throw new IllegalStateException("Cannot load player energy policy", failure); } + } + + static PowerRequest.Consumer system(String path) { + return new PowerRequest.Consumer(ResourceLocation.fromNamespaceAndPath("skysleylines", path)); + } + void checkThread() { + if (!server.isSameThread()) throw new IllegalStateException("Player power requires the server thread"); + } + long now() { return server.overworld().getGameTime(); } + boolean busy() { return ledger.mutationInProgress(); } + boolean currencyActive(ResourceLocation currency) { return ledger.activeCurrencies().contains(currency); } + MagicAmount rate(ResourceLocation currency) { return ledger.savedRate(currency); } + ServerPlayer online(UUID id) { return server.getPlayerList().getPlayer(id); } + ServerLevel level(ResourceLocation id) { return server.getLevel(ResourceKey.create(Registries.DIMENSION, id)); } + + void reload() { + try { + var updated = PlayerEnergyPolicy.read(server); + if (!policy.sameRules(updated)) { + policy = updated; + jobs.policyChanged(); + } + } catch (IOException | RuntimeException failure) { + LogUtils.getLogger().error("Keeping previous player energy policy after failed reload", failure); + } + } + + PlayerEnergyPolicy.Rule rule(UUID player, ResourceLocation currency) { + var stored = player == null ? null : data.pool(player, currency); + return policy.rule(currency, stored == null ? ABSENT : stored.settings()); + } + private Pool pool(ResourceLocation currency, StoredPool stored) { + if (stored == null) return new Pool(0, ABSENT, false); + var settings = policy.rule(currency, stored.settings()).settings(); + return new Pool(stored.units(), settings, settings.enabled() && ledger.activeCurrencies().contains(currency)); + } + Pool pool(UUID player, ResourceLocation currency) { return pool(currency, data.pool(player, currency)); } + long room(UUID player, ResourceLocation currency) { + Pool pool = pool(player, currency); + return Math.max(0L, pool.settings().capacity() - pool.balance()); + } + + @Override public Snapshot inspect(UUID player) { + checkThread(); + Objects.requireNonNull(player); + var result = new TreeMap(); + data.players.getOrDefault(player, Map.of()).forEach((currency, stored) -> result.put(currency, pool(currency, stored))); + return new Snapshot(player, result); + } + + private Result guarded(Supplier action) { + checkThread(); + return ledger.exclusive(Result.of(REENTRANT), () -> { + try { return action.get(); } + catch (ArithmeticException failure) { return Result.of(OVERFLOW); } + }); + } + + @Override public Result configure(UUID player, ResourceLocation currency, Settings settings, PowerRequest.Consumer consumer) { + return guarded(() -> { + if (rate(currency) == null) return Result.of(UNKNOWN_CURRENCY); + if (settings.autoRate() != null) LeySaveLimits.checkAmount(settings.autoRate(), "Player recharge rate"); + return update(player, currency, new StoredPool(settings, pool(player, currency).balance()), Operation.CONFIGURE, + MagicAmount.ZERO, consumer); + }); + } + @Override public Result setBalance(UUID player, ResourceLocation currency, long units, PowerRequest.Consumer consumer) { + return guarded(() -> setBalanceLocked(player, currency, units, Operation.ADJUST, consumer)); + } + @Override public Result adjust(UUID player, ResourceLocation currency, long delta, PowerRequest.Consumer consumer) { + return guarded(() -> setBalanceLocked(player, currency, Math.addExact(pool(player, currency).balance(), delta), Operation.ADJUST, consumer)); + } + @Override public Result spend(UUID player, ResourceLocation currency, long units, PowerRequest.Consumer consumer) { + return guarded(() -> { + if (units <= 0) return Result.of(INVALID_AMOUNT); + if (rate(currency) == null) return Result.of(UNKNOWN_CURRENCY); + var pool = pool(player, currency); + if (!pool.active()) return Result.of(DORMANT); + if (units > pool.balance()) return Result.of(INSUFFICIENT_POWER); + return setBalanceLocked(player, currency, pool.balance() - units, Operation.SPEND, consumer); + }); + } + private Result setBalanceLocked(UUID player, ResourceLocation currency, long units, Operation operation, PowerRequest.Consumer consumer) { + if (units < 0) return Result.of(INVALID_AMOUNT); + if (rate(currency) == null) return Result.of(UNKNOWN_CURRENCY); + var before = pool(player, currency); + if (data.pool(player, currency) == null || (units > before.balance() && !before.active())) return Result.of(DORMANT); + if (units > before.balance() && units > before.settings().capacity()) return Result.of(CAPACITY); + return update(player, currency, new StoredPool(data.pool(player, currency).settings(), units), operation, + MagicAmount.of(Math.abs(units - before.balance())), consumer); + } + private Result update(UUID player, ResourceLocation currency, StoredPool next, Operation operation, MagicAmount amount, + PowerRequest.Consumer consumer) { + var changes = Map.of(player, change(player, currency, next)); + var receipt = receipt(operation, currency, amount, consumer, null, null, changes, null, null, null); + if (next.equals(data.pool(player, currency))) return Result.committed(receipt); + return commit(receipt, () -> data.put(player, currency, next)); + } + + private Change change(UUID player, ResourceLocation currency, StoredPool next) { + return new Change(player, pool(player, currency), pool(currency, next)); + } + private StoredPool withBalance(UUID player, ResourceLocation currency, long units) { + return new StoredPool(data.pool(player, currency).settings(), units); + } + private static Receipt receipt(Operation operation, ResourceLocation currency, MagicAmount amount, + PowerRequest.Consumer consumer, NexusId nexus, UUID drop, Map changes, + AccountSnapshot before, AccountSnapshot after, GlobalPos anchor) { + return new Receipt(operation, currency, amount, Objects.requireNonNull(consumer), Optional.ofNullable(nexus), + Optional.ofNullable(drop), changes, Optional.ofNullable(before), Optional.ofNullable(after), Optional.ofNullable(anchor)); + } + private Status before(Receipt receipt) { + try { return NeoForge.EVENT_BUS.post(new PlayerPowerEvent.Pre(server, receipt)).isCanceled() ? CANCELLED : SUCCESS; } + catch (RuntimeException failure) { + LogUtils.getLogger().error("Player power protection check failed", failure); + return CHECK_FAILED; + } + } + private void post(Receipt receipt) { + try { NeoForge.EVENT_BUS.post(new PlayerPowerEvent.Post(server, receipt)); } + catch (RuntimeException failure) { LogUtils.getLogger().error("Player power observer failed after commit", failure); } + } + private Status prepareSave() { + try { world.preparePlayerSave(); return SUCCESS; } + catch (RuntimeException failure) { + LogUtils.getLogger().error("Player power storage preparation failed", failure); + return STORAGE_FAILED; + } + } + private Result commit(Receipt receipt, Runnable change) { + Status check = before(receipt); + if (check != SUCCESS) return Result.of(check); + check = prepareSave(); + if (check != SUCCESS) return Result.of(check); + change.run(); + world.setDirty(); + post(receipt); + return Result.committed(receipt); + } + + @Override public Submission withdraw(ServerPlayer player, ResourceLocation currency, long units, Timing timing, PowerRequest.Consumer consumer) { + return jobs.submit(Operation.WITHDRAW, player, null, player.serverLevel(), player.blockPosition(), currency, units, timing, consumer); + } + @Override public Submission deposit(ServerPlayer player, ResourceLocation currency, long units, Timing timing, PowerRequest.Consumer consumer) { + return jobs.submit(Operation.DEPOSIT, player, null, player.serverLevel(), player.blockPosition(), currency, units, timing, consumer); + } + @Override public Submission transfer(ServerPlayer from, ServerPlayer to, ResourceLocation currency, long units, Timing timing, PowerRequest.Consumer consumer) { + return jobs.submit(Operation.TRANSFER, from, to, null, null, currency, units, timing, consumer); + } + @Override public Submission convert(ServerLevel level, BlockPos pos, ResourceLocation currency, MagicAmount raw, Timing timing, PowerRequest.Consumer consumer) { + return jobs.submit(Operation.CONVERT, null, null, level, pos, currency, raw, timing, consumer); + } + @Override public Optional job(UUID id) { checkThread(); return jobs.read(id); } + @Override public boolean cancel(UUID id) { checkThread(); return !busy() && jobs.cancel(id, CANCELLED); } + + Result movePlayers(UUID from, UUID to, ResourceLocation currency, long units, PowerRequest.Consumer consumer) { + return guarded(() -> { + var debit = withBalance(from, currency, pool(from, currency).balance() - units); + var credit = withBalance(to, currency, pool(to, currency).balance() + units); + var receipt = receipt(Operation.TRANSFER, currency, MagicAmount.of(units), consumer, null, null, + Map.of(from, change(from, currency, debit), to, change(to, currency, credit)), null, null, null); + return commit(receipt, () -> { + data.put(from, currency, debit); + data.put(to, currency, credit); + }); + }); + } + + Result deposit(UUID player, GlobalPos anchor, NexusSnapshot node, ResourceLocation currency, long units, + PowerRequest.Consumer consumer) { + return guarded(() -> { + var id = node.nexus().id(); + var debit = withBalance(player, currency, pool(player, currency).balance() - units); + var current = node.account(); + var amounts = new TreeMap<>(current.currencies()); + amounts.merge(currency, units, Math::addExact); + var amount = MagicAmount.of(units); + var next = new AccountSnapshot(current.capacity(), current.raw(), amounts, + current.total().add(amount.divide(rate(currency))), current.tick()); + Runnable deposit = ledger.prepareDeposit(id, node.nexus().capacity(world.profile()), now(), currency, units); + var receipt = receipt(Operation.DEPOSIT, currency, amount, consumer, id, null, + Map.of(player, change(player, currency, debit)), current, next, anchor); + return commit(receipt, () -> { + deposit.run(); + data.put(player, currency, debit); + }); + }); + } + + Result useNode(Operation operation, UUID player, ServerLevel level, BlockPos pos, NexusSnapshot node, + ResourceLocation currency, MagicAmount amount, PowerRequest.Consumer consumer) { + boolean withdraw = operation == Operation.WITHDRAW; + var request = new PowerRequest(pos.getX(), pos.getY(), pos.getZ(), + withdraw ? PowerRequest.Operation.SPEND : PowerRequest.Operation.CONVERT, currency, amount, consumer); + var credit = withdraw ? withBalance(player, currency, pool(player, currency).balance() + amount.wholeLongExact()) : null; + var changes = withdraw ? Map.of(player, change(player, currency, credit)) : Map.of(); + class Checks implements NexusLedger.Hooks { + Receipt receipt; + Status denied = SUCCESS; + @Override public boolean before(PowerResult.Receipt proposed) { + receipt = receipt(operation, currency, amount, consumer, proposed.nexus(), null, + changes, proposed.before(), proposed.after(), GlobalPos.of(level.dimension(), pos)); + if (NeoForge.EVENT_BUS.post(new NexusTransactionEvent.Pre(level, proposed)).isCanceled()) return false; + denied = PlayerEnergy.this.before(receipt); + if (denied == SUCCESS && withdraw) denied = prepareSave(); + return denied == SUCCESS; + } + @Override public void after(PowerResult.Receipt committed) { + if (withdraw) data.put(player, currency, credit); + world.setDirty(); + post(receipt); + NeoForge.EVENT_BUS.post(new NexusTransactionEvent.Post(level, committed)); + } + @Override public void failure(RuntimeException failure) { + LogUtils.getLogger().error("Nexus listener failed during player power operation", failure); + } + } + var checks = new Checks(); + var result = ledger.transact(node.nexus().id(), node.nexus().capacity(world.profile()), now(), request, checks); + if (result.succeeded()) return Result.committed(checks.receipt); + if (checks.denied != SUCCESS) return Result.of(checks.denied); + return Result.of(switch (result.status()) { + case INSUFFICIENT_RAW, INSUFFICIENT_CURRENCY -> INSUFFICIENT_POWER; + case INACTIVE_CURRENCY -> DORMANT; + case NON_INTEGRAL_OUTPUT, INVALID_AMOUNT -> INVALID_AMOUNT; + default -> Status.valueOf(result.status().name()); + }); + } + + void jobFinished(Job job) { + ledger.exclusive(false, () -> { + try { NeoForge.EVENT_BUS.post(new PlayerPowerEvent.JobFinished(server, job)); } + catch (RuntimeException failure) { LogUtils.getLogger().error("Energy job observer failed", failure); } + return true; + }); + } + + private record DeathDebit(ResourceLocation currency, StoredPool pool, Drop drop, Receipt receipt) {} + + void rememberDeath(LivingDeathEvent event, ServerPlayer player) { deaths.put(player.getUUID(), event); } + + void checkDeath(ServerPlayer player, boolean respawn) { + UUID owner = player.getUUID(); + var event = deaths.get(owner); + if (event == null) return; + if (event.isCanceled() || player.isAlive()) { deaths.remove(owner); return; } + if (!respawn && player.deathTime <= 0) return; + deaths.remove(owner); + jobs.stopFor(owner, DIED); + ledger.exclusive(false, () -> { + var level = player.serverLevel(); + var anchor = GlobalPos.of(level.dimension(), player.blockPosition()); + var debits = new ArrayList(); + for (var entry : new TreeMap<>(inspect(owner).pools()).entrySet()) { + var currency = entry.getKey(); + var pool = entry.getValue(); + if (!pool.active() || pool.balance() == 0) continue; + var rule = rule(owner, currency); + if (rule.death() == DeathMode.KEEP + || (rule.honorKeepInventory() && level.getGameRules().getBoolean(GameRules.RULE_KEEPINVENTORY))) continue; + var dropId = rule.death() == DeathMode.DROP ? UUID.randomUUID() : null; + var debit = withBalance(owner, currency, 0); + var drop = dropId == null ? null : new Drop(owner, level.dimension().location(), player.position(), + now() + rule.lifetime(), rule.ownerOnly(), Map.of(currency, pool.balance())); + var receipt = receipt(Operation.DEATH, currency, MagicAmount.of(pool.balance()), DEATH, null, dropId, + Map.of(owner, change(owner, currency, debit)), null, null, anchor); + if (before(receipt) == SUCCESS) debits.add(new DeathDebit(currency, debit, drop, receipt)); + } + if (debits.isEmpty() || prepareSave() != SUCCESS) return false; + for (var debit : debits) { + if (debit.drop != null) { + var id = debit.receipt.drop().orElseThrow(); + data.drops.put(id, debit.drop); + expirations.add(id); + } + data.put(owner, debit.currency, debit.pool); + } + world.setDirty(); + for (var debit : debits) post(debit.receipt); + return true; + }); + } + + void changedDimension(ServerPlayer player) { jobs.stopFor(player.getUUID(), MOVED); } + + void logout(ServerPlayer player) { + checkDeath(player, false); + deaths.remove(player.getUUID()); + jobs.stopFor(player.getUUID(), DISCONNECTED); + } + + void pickup(UUID id, ServerPlayer player) { + var drop = data.drops.get(id); + if (drop == null || drop.expires() <= now() || !player.isAlive() + || !drop.dimension().equals(player.serverLevel().dimension().location()) + || player.distanceToSqr(drop.position()) > 2.25 + || (drop.ownerOnly() && !drop.owner().equals(player.getUUID()))) return; + var receiver = player.getUUID(); + for (var currency : new TreeSet<>(drop.balances().keySet())) { + guarded(() -> { + var current = data.drops.get(id); + var before = pool(receiver, currency); + if (!before.active()) return Result.of(DORMANT); + long held = current.balances().get(currency); + long moved = Math.min(held, room(receiver, currency)); + if (moved <= 0) return Result.of(CAPACITY); + var credit = withBalance(receiver, currency, before.balance() + moved); + var balances = new TreeMap<>(current.balances()); + if (moved == held) balances.remove(currency); else balances.put(currency, held - moved); + var receipt = receipt(Operation.PICKUP, currency, MagicAmount.of(moved), PICKUP, null, id, + Map.of(receiver, change(receiver, currency, credit)), null, null, + GlobalPos.of(player.serverLevel().dimension(), BlockPos.containing(drop.position()))); + return commit(receipt, () -> { + if (balances.isEmpty()) removeDrop(id); + else data.drops.put(id, new Drop(current.owner(), current.dimension(), current.position(), + current.expires(), current.ownerOnly(), balances)); + data.put(receiver, currency, credit); + }); + }); + } + } + + private void removeDrop(UUID id) { + expirations.remove(id); + data.drops.remove(id); + var orb = orbs.remove(id); + if (orb != null) orb.discard(); + } + + boolean validProjection(UUID id, EnergyOrb orb) { + if (orbs.get(id) != orb) return false; + var drop = data.drops.get(id); + return drop != null && drop.expires() > now() && orb.level().hasChunkAt(orb.blockPosition()); + } + + void tick() { + for (var player : server.getPlayerList().getPlayers()) checkDeath(player, false); + jobs.tick(server.getPlayerList().getPlayers()); + for (int i = 0; i < 64 && !expirations.isEmpty() && data.drops.get(expirations.first()).expires() <= now(); i++) { + removeDrop(expirations.first()); + world.setDirty(); + } + for (int i = 0; i < Math.min(32, data.drops.size()); i++) { + var id = lastProjection == null ? data.drops.firstKey() : data.drops.higherKey(lastProjection); + if (id == null) { lastProjection = null; break; } + lastProjection = id; + var drop = data.drops.get(id); + var existing = orbs.get(id); + if (drop == null || drop.expires() <= now() || (existing != null && !existing.isRemoved())) continue; + var level = level(drop.dimension()); + if (level == null || !level.hasChunkAt(BlockPos.containing(drop.position()))) continue; + var orb = new EnergyOrb(level, id, drop.position()); + if (level.addFreshEntity(orb)) orbs.put(id, orb); + } + orbs.values().removeIf(EnergyOrb::isRemoved); + } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergyData.java b/src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergyData.java new file mode 100644 index 0000000..30a1c21 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergyData.java @@ -0,0 +1,126 @@ +package zone.moddev.mc.skysleylines.server; + +import java.util.*; +import net.minecraft.nbt.*; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.phys.Vec3; +import zone.moddev.mc.skysleylines.api.PlayerPowerService.Settings; +import zone.moddev.mc.skysleylines.core.LeySaveLimits; +import zone.moddev.mc.skysleylines.core.NexusLedger; +import static zone.moddev.mc.skysleylines.server.LeyWorldData.*; + +/** The only persistent copy of pool balances and dropped energy. */ +final class PlayerEnergyData { + record StoredPool(Settings settings, long units) {} + record Drop(UUID owner, ResourceLocation dimension, Vec3 position, long expires, boolean ownerOnly, + Map balances) { + Drop { balances = Map.copyOf(balances); } + } + + final Map> players = new TreeMap<>(); + final NavigableMap drops = new TreeMap<>(); + + StoredPool pool(UUID player, ResourceLocation currency) { + var pools = players.get(player); + return pools == null ? null : pools.get(currency); + } + + void put(UUID player, ResourceLocation currency, StoredPool pool) { + players.computeIfAbsent(player, ignored -> new TreeMap<>()).put(currency, pool); + } + + CompoundTag write() { + var root = new CompoundTag(); + var wallets = new ListTag(); + players.forEach((id, pools) -> { + var wallet = new CompoundTag(); + wallet.putUUID("player", id); + var rows = new ListTag(); + pools.forEach((currency, pool) -> { + var row = currencyTag(currency, pool.units()); + row.putBoolean("enabled", pool.settings().enabled()); + row.putLong("capacity", pool.settings().capacity()); + if (pool.settings().autoRate() != null) putAmount(row, "autoRate", pool.settings().autoRate()); + rows.add(row); + }); + wallet.put("pools", rows); + wallets.add(wallet); + }); + root.put("players", wallets); + var loot = new ListTag(); + drops.forEach((id, drop) -> { + var row = new CompoundTag(); + row.putUUID("id", id); + row.putUUID("owner", drop.owner()); + row.putString("dimension", drop.dimension().toString()); + row.putDouble("x", drop.position().x); + row.putDouble("y", drop.position().y); + row.putDouble("z", drop.position().z); + row.putLong("expires", drop.expires()); + row.putBoolean("ownerOnly", drop.ownerOnly()); + var balances = new ListTag(); + new TreeMap<>(drop.balances()).forEach((currency, units) -> balances.add(currencyTag(currency, units))); + row.put("balances", balances); + loot.add(row); + }); + root.put("drops", loot); + return root; + } + + void read(CompoundTag root, NexusLedger ledger) { + for (Tag entry : compounds(root, "players")) { + var row = (CompoundTag) entry; + var pools = new TreeMap(); + for (Tag value : compounds(row, "pools")) { + var pool = (CompoundTag) value; + var id = currency(pool, ledger); + require(pool, "enabled", Tag.TAG_BYTE); + require(pool, "capacity", Tag.TAG_LONG); + var rate = pool.contains("autoRate") ? amount(pool, "autoRate") : null; + if (rate != null) LeySaveLimits.checkAmount(rate, "Player recharge rate"); + var settings = new Settings(pool.getBoolean("enabled"), pool.getLong("capacity"), rate); + if (pools.putIfAbsent(id, new StoredPool(settings, pool.getLong("units"))) != null) + throw new IllegalArgumentException("Duplicate pool " + id); + } + if (players.putIfAbsent(row.getUUID("player"), pools) != null) throw new IllegalArgumentException("Duplicate wallet"); + } + for (Tag entry : compounds(root, "drops")) { + var row = (CompoundTag) entry; + require(row, "dimension", Tag.TAG_STRING); + for (String key : new String[]{"x", "y", "z"}) { + require(row, key, Tag.TAG_DOUBLE); + if (!Double.isFinite(row.getDouble(key))) throw new IllegalArgumentException("Invalid energy drop position"); + } + require(row, "expires", Tag.TAG_LONG); + require(row, "ownerOnly", Tag.TAG_BYTE); + var balances = new TreeMap(); + for (Tag value : compounds(row, "balances")) { + var balance = (CompoundTag) value; + var id = currency(balance, ledger); + long units = balance.getLong("units"); + if (units == 0 || balances.putIfAbsent(id, units) != null) throw new IllegalArgumentException("Invalid drop balance"); + } + long expires = row.getLong("expires"); + if (expires < 0 || balances.isEmpty()) throw new IllegalArgumentException("Invalid energy drop"); + var position = new Vec3(row.getDouble("x"), row.getDouble("y"), row.getDouble("z")); + var drop = new Drop(row.getUUID("owner"), identifier(row.getString("dimension")), position, + expires, row.getBoolean("ownerOnly"), balances); + if (drops.putIfAbsent(row.getUUID("id"), drop) != null) throw new IllegalArgumentException("Duplicate energy drop"); + } + } + + private static ResourceLocation currency(CompoundTag row, NexusLedger ledger) { + require(row, "id", Tag.TAG_STRING); + require(row, "units", Tag.TAG_LONG); + var id = identifier(row.getString("id")); + if (ledger.savedRate(id) == null || row.getLong("units") < 0) throw new IllegalArgumentException("Invalid saved pool currency " + id); + return id; + } + + private static CompoundTag currencyTag(ResourceLocation currency, long units) { + var row = new CompoundTag(); + row.putString("id", currency.toString()); + row.putLong("units", units); + return row; + } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergyEvents.java b/src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergyEvents.java new file mode 100644 index 0000000..9438929 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergyEvents.java @@ -0,0 +1,37 @@ +package zone.moddev.mc.skysleylines.server; + +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.neoforge.event.OnDatapackSyncEvent; +import net.neoforged.neoforge.event.entity.living.LivingDeathEvent; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; + +public final class PlayerEnergyEvents { + private PlayerEnergyEvents() {} + + public static void tick(ServerTickEvent.Post event) { LeyServerContext.playerEnergy(event.getServer()).tick(); } + + public static void death(LivingDeathEvent event) { + if (event.getEntity() instanceof ServerPlayer player) + LeyServerContext.playerEnergy(player.serverLevel().getServer()).rememberDeath(event, player); + } + + public static void clone(PlayerEvent.Clone event) { + if (event.isWasDeath() && event.getOriginal() instanceof ServerPlayer player) + LeyServerContext.playerEnergy(player.serverLevel().getServer()).checkDeath(player, true); + } + + public static void logout(PlayerEvent.PlayerLoggedOutEvent event) { + if (event.getEntity() instanceof ServerPlayer player) + LeyServerContext.playerEnergy(player.serverLevel().getServer()).logout(player); + } + + public static void dimension(PlayerEvent.PlayerChangedDimensionEvent event) { + if (event.getEntity() instanceof ServerPlayer player) + LeyServerContext.playerEnergy(player.serverLevel().getServer()).changedDimension(player); + } + + public static void reload(OnDatapackSyncEvent event) { + if (event.getPlayer() == null) LeyServerContext.playerEnergy(event.getPlayerList().getServer()).reload(); + } +} diff --git a/src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergyPolicy.java b/src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergyPolicy.java new file mode 100644 index 0000000..542cb81 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysleylines/server/PlayerEnergyPolicy.java @@ -0,0 +1,155 @@ +package zone.moddev.mc.skysleylines.server; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.io.Reader; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.storage.LevelResource; +import net.neoforged.fml.loading.FMLPaths; +import zone.moddev.mc.skysleylines.api.MagicAmount; +import zone.moddev.mc.skysleylines.api.PlayerPowerService.DeathMode; +import zone.moddev.mc.skysleylines.api.PlayerPowerService.Settings; +import zone.moddev.mc.skysleylines.core.PlayerEnergyDefaults; + +/** Applies sparse overlays in data pack, instance, world order. */ +final class PlayerEnergyPolicy { + private static final String DIRECTORY = "player_energy"; + private static final String FILE = "skysleylines-player-energy.json"; + private static final Set KEYS = Set.of("enabled", "capacity", "autoRate", "instant", + "transferRateLimit", "conversionRateLimit", "sameDimension", "maxDistance", "death", + "honorKeepInventory", "ownerOnly", "dropLifetime"); + + record Rule(Settings settings, boolean instant, MagicAmount transferLimit, MagicAmount conversionLimit, + boolean sameDimension, int maxDistance, DeathMode death, boolean honorKeepInventory, + boolean ownerOnly, int lifetime) {} + + private record Layer(JsonObject defaults, Map currencies) {} + private final List layers; + + private PlayerEnergyPolicy(List layers) { this.layers = List.copyOf(layers); } + + static PlayerEnergyPolicy read(MinecraftServer server) throws IOException { + Map data = new TreeMap<>(); + var resources = server.getResourceManager().listResources(DIRECTORY, id -> id.getPath().endsWith(".json")); + for (var entry : new TreeMap<>(resources).entrySet()) { + var id = entry.getKey(); + var currency = ResourceLocation.fromNamespaceAndPath(id.getNamespace(), + id.getPath().substring(DIRECTORY.length() + 1, id.getPath().length() - 5)); + try (var reader = entry.getValue().openAsReader()) { + data.put(currency, checked(parse(reader))); + } catch (IOException | RuntimeException failure) { + throw new IOException("Invalid player energy data pack rule " + id, failure); + } + } + return new PlayerEnergyPolicy(List.of(new Layer(new JsonObject(), data), + readLayer(FMLPaths.CONFIGDIR.get().resolve(FILE)), + readLayer(server.getWorldPath(LevelResource.ROOT).resolve("serverconfig").resolve(FILE)))); + } + + boolean sameRules(PlayerEnergyPolicy other) { return layers.equals(other.layers); } + + Rule rule(ResourceLocation currency, Settings requested) { + var values = new JsonObject(); + for (Layer layer : layers) { + overlay(values, layer.defaults()); + overlay(values, layer.currencies().get(currency)); + } + var defaults = PlayerEnergyDefaults.forCurrency(currency); + return new Rule(new Settings(bool(values, "enabled", requested.enabled()), + number(values, "capacity", requested.capacity()), + rate(values, "autoRate", requested.autoRate())), bool(values, "instant", defaults.instant()), + rate(values, "transferRateLimit", defaults.transferRateLimit()), rate(values, "conversionRateLimit", defaults.conversionRateLimit()), + bool(values, "sameDimension", defaults.sameDimension()), (int) number(values, "maxDistance", defaults.maxDistance()), + values.has("death") ? DeathMode.valueOf(values.get("death").getAsString()) : defaults.death(), + bool(values, "honorKeepInventory", defaults.honorKeepInventory()), bool(values, "ownerOnly", defaults.ownerOnly()), + (int) number(values, "dropLifetime", defaults.dropLifetime())); + } + + private static void overlay(JsonObject target, JsonObject source) { + if (source != null) source.entrySet().forEach(entry -> target.add(entry.getKey(), entry.getValue())); + } + + private static Layer readLayer(Path path) throws IOException { + if (Files.notExists(path)) return new Layer(new JsonObject(), Map.of()); + try (var reader = Files.newBufferedReader(path)) { + var root = parse(reader); + if (!Set.of("defaults", "currencies").containsAll(root.keySet())) throw new IllegalArgumentException("Unknown policy section"); + var defaults = root.has("defaults") ? checked(root.getAsJsonObject("defaults")) : new JsonObject(); + var rules = new TreeMap(); + if (root.has("currencies")) root.getAsJsonObject("currencies").entrySet().forEach(entry -> + rules.put(LeyWorldData.identifier(entry.getKey()), checked(entry.getValue().getAsJsonObject()))); + return new Layer(defaults, rules); + } catch (IOException | RuntimeException failure) { + throw new IOException("Invalid player energy policy " + path, failure); + } + } + + private static JsonObject parse(Reader reader) { return JsonParser.parseReader(reader).getAsJsonObject(); } + + private static JsonObject checked(JsonObject rule) { + for (var entry : rule.entrySet()) { + var key = entry.getKey(); + var value = entry.getValue(); + if (!KEYS.contains(key)) throw new IllegalArgumentException("Unknown player energy setting " + key); + switch (key) { + case "enabled", "instant", "sameDimension", "honorKeepInventory", "ownerOnly" -> { + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) + throw new IllegalArgumentException("Expected boolean for " + key); + } + case "capacity" -> integer(value); + case "maxDistance" -> bounded(value, 0, 60_000_000); + case "dropLifetime" -> bounded(value, 1, 1_728_000); + case "death" -> DeathMode.valueOf(string(value)); + default -> { + if (value.isJsonNull() && !key.equals("autoRate")) throw new IllegalArgumentException("Expected rate for " + key); + if (!value.isJsonNull()) exactRate(string(value)); + } + } + } + return rule; + } + + private static boolean bool(JsonObject values, String key, boolean fallback) { + return values.has(key) ? values.get(key).getAsBoolean() : fallback; + } + + private static long number(JsonObject values, String key, long fallback) { + return values.has(key) ? integer(values.get(key)) : fallback; + } + + private static MagicAmount rate(JsonObject values, String key, MagicAmount fallback) { + if (!values.has(key)) return fallback; + return values.get(key).isJsonNull() ? null : exactRate(string(values.get(key))); + } + + private static String string(JsonElement value) { + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) + throw new IllegalArgumentException("Expected a string"); + return value.getAsString(); + } + + private static long integer(JsonElement value) { + if (!value.isJsonPrimitive() || !value.getAsString().matches("0|[1-9][0-9]{0,18}")) + throw new IllegalArgumentException("Expected a nonnegative whole number"); + return Long.parseLong(value.getAsString()); + } + + private static void bounded(JsonElement value, int min, int max) { + long number = integer(value); + if (number < min || number > max) throw new IllegalArgumentException("Expected " + min + " through " + max); + } + + static MagicAmount exactRate(String text) { + if (text.length() > 256 || !text.matches("[1-9][0-9]*(/[1-9][0-9]*)?")) + throw new IllegalArgumentException("Expected positive exact rate"); + var parts = text.split("/"); + return new MagicAmount(new BigInteger(parts[0]), parts.length == 1 ? BigInteger.ONE : new BigInteger(parts[1])); + } +} diff --git a/src/main/resources/assets/skysleylines/lang/de_de.json b/src/main/resources/assets/skysleylines/lang/de_de.json index 6f0ffa6..567c3b3 100644 --- a/src/main/resources/assets/skysleylines/lang/de_de.json +++ b/src/main/resources/assets/skysleylines/lang/de_de.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Vorgabe für eine vollständige Aufladung beim Anlegen des Leyprofils. Ein Minecraft-Tag hat 24.000 Ticks.", "config.skysleylines.rechargeTicksOverride": "Angepasste Ladezeit", "config.skysleylines.rechargeTicksOverride.tooltip": "0 nutzt die gespeicherte Basis-Ladezeit. Erlaubt sind 1 bis 1.728.000 Ticks. Öffne die Welt erneut. Welten mit der gleichen Serverkonfiguration teilen diese Einstellung.", + "config.skysleylines.nexusCapacityMultiplier": "Multiplikator für Nexuskapazität", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Skaliert die Rohkapazität jedes Nexus. Der Standardwert 0.01 ergibt ein Hundertstel der Basiskapazität. Öffne die Welt erneut.", "config.skysleylines.defaultVisionStyle": "Standardstil der Leysicht", "config.skysleylines.defaultVisionStyle.tooltip": "Aussehen der Operatorsicht und der Effekte mit Standardstil. Welten mit der gleichen Serverkonfiguration teilen diese Einstellung.", "config.skysleylines.style.river": "Fluss", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": "%s Einheiten · %s Einheiten pro Rohmagie", "meter.skysleylines.currency_inactive": "%s Einheiten · %s Einheiten pro Rohmagie · Inaktiv", "meter.skysleylines.more": "%s weitere Währungen. Siehe /skysleylines account.", - "command.skysleylines.profile": "Gespeichertes Leylayout v%s, nur Oberwelt: %s. Effektive Ladezeit: %s Ticks.", + "command.skysleylines.profile": "Gespeichertes Leylayout v%s, nur Oberwelt: %s. Effektive Ladezeit: %s Ticks. nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Leyfeld %s: aktiv=%s Stärke=%s Linie=%s Nexus=%s Höhe=%s Widerstand=%s Vorrat=%s", "command.skysleylines.sample.unavailable": "nicht verfügbar", "command.skysleylines.locate.overworld": "Ley-Nexus lassen sich derzeit in der Oberwelt finden.", diff --git a/src/main/resources/assets/skysleylines/lang/en_au.json b/src/main/resources/assets/skysleylines/lang/en_au.json index 4c29d50..9427cb5 100644 --- a/src/main/resources/assets/skysleylines/lang/en_au.json +++ b/src/main/resources/assets/skysleylines/lang/en_au.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Sets the default full refill time in ticks when a world first initializes its ley profile. A Minecraft day is 24,000 ticks.", "config.skysleylines.rechargeTicksOverride": "Recharge Override", "config.skysleylines.rechargeTicksOverride.tooltip": "Enter 0 to use the saved base rate, or 1 to 1,728,000 ticks. Reopen the world to apply. Worlds using the instance server config share this setting.", + "config.skysleylines.nexusCapacityMultiplier": "Nexus Capacity Multiplier", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Scales the raw capacity of each nexus. The default 0.01 gives one hundredth of the base capacity. Reopen the world to apply.", "config.skysleylines.defaultVisionStyle": "Default Ley Vision Style", "config.skysleylines.defaultVisionStyle.tooltip": "Sets the appearance of operator vision and effects that use the default style. Worlds using the instance server config share this setting.", "config.skysleylines.style.river": "River", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": " %s units · %s units per raw", "meter.skysleylines.currency_inactive": " %s units · %s units per raw · Inactive", "meter.skysleylines.more": "%s more currencies. See /skysleylines account.", - "command.skysleylines.profile": "Saved ley layout v%s, Overworld only: %s; effectiveRechargeTicks=%s", + "command.skysleylines.profile": "Saved ley layout v%s, Overworld only: %s; effectiveRechargeTicks=%s nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Ley %s: active=%s strength=%s line=%s nexus=%s vertical=%s resistance=%s reserve=%s", "command.skysleylines.sample.unavailable": "unavailable", "command.skysleylines.locate.overworld": "Ley nexuses can currently be located in the Overworld.", diff --git a/src/main/resources/assets/skysleylines/lang/en_ca.json b/src/main/resources/assets/skysleylines/lang/en_ca.json index 4c29d50..9427cb5 100644 --- a/src/main/resources/assets/skysleylines/lang/en_ca.json +++ b/src/main/resources/assets/skysleylines/lang/en_ca.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Sets the default full refill time in ticks when a world first initializes its ley profile. A Minecraft day is 24,000 ticks.", "config.skysleylines.rechargeTicksOverride": "Recharge Override", "config.skysleylines.rechargeTicksOverride.tooltip": "Enter 0 to use the saved base rate, or 1 to 1,728,000 ticks. Reopen the world to apply. Worlds using the instance server config share this setting.", + "config.skysleylines.nexusCapacityMultiplier": "Nexus Capacity Multiplier", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Scales the raw capacity of each nexus. The default 0.01 gives one hundredth of the base capacity. Reopen the world to apply.", "config.skysleylines.defaultVisionStyle": "Default Ley Vision Style", "config.skysleylines.defaultVisionStyle.tooltip": "Sets the appearance of operator vision and effects that use the default style. Worlds using the instance server config share this setting.", "config.skysleylines.style.river": "River", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": " %s units · %s units per raw", "meter.skysleylines.currency_inactive": " %s units · %s units per raw · Inactive", "meter.skysleylines.more": "%s more currencies. See /skysleylines account.", - "command.skysleylines.profile": "Saved ley layout v%s, Overworld only: %s; effectiveRechargeTicks=%s", + "command.skysleylines.profile": "Saved ley layout v%s, Overworld only: %s; effectiveRechargeTicks=%s nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Ley %s: active=%s strength=%s line=%s nexus=%s vertical=%s resistance=%s reserve=%s", "command.skysleylines.sample.unavailable": "unavailable", "command.skysleylines.locate.overworld": "Ley nexuses can currently be located in the Overworld.", diff --git a/src/main/resources/assets/skysleylines/lang/en_en.json b/src/main/resources/assets/skysleylines/lang/en_en.json index 4c29d50..9427cb5 100644 --- a/src/main/resources/assets/skysleylines/lang/en_en.json +++ b/src/main/resources/assets/skysleylines/lang/en_en.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Sets the default full refill time in ticks when a world first initializes its ley profile. A Minecraft day is 24,000 ticks.", "config.skysleylines.rechargeTicksOverride": "Recharge Override", "config.skysleylines.rechargeTicksOverride.tooltip": "Enter 0 to use the saved base rate, or 1 to 1,728,000 ticks. Reopen the world to apply. Worlds using the instance server config share this setting.", + "config.skysleylines.nexusCapacityMultiplier": "Nexus Capacity Multiplier", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Scales the raw capacity of each nexus. The default 0.01 gives one hundredth of the base capacity. Reopen the world to apply.", "config.skysleylines.defaultVisionStyle": "Default Ley Vision Style", "config.skysleylines.defaultVisionStyle.tooltip": "Sets the appearance of operator vision and effects that use the default style. Worlds using the instance server config share this setting.", "config.skysleylines.style.river": "River", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": " %s units · %s units per raw", "meter.skysleylines.currency_inactive": " %s units · %s units per raw · Inactive", "meter.skysleylines.more": "%s more currencies. See /skysleylines account.", - "command.skysleylines.profile": "Saved ley layout v%s, Overworld only: %s; effectiveRechargeTicks=%s", + "command.skysleylines.profile": "Saved ley layout v%s, Overworld only: %s; effectiveRechargeTicks=%s nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Ley %s: active=%s strength=%s line=%s nexus=%s vertical=%s resistance=%s reserve=%s", "command.skysleylines.sample.unavailable": "unavailable", "command.skysleylines.locate.overworld": "Ley nexuses can currently be located in the Overworld.", diff --git a/src/main/resources/assets/skysleylines/lang/en_gb.json b/src/main/resources/assets/skysleylines/lang/en_gb.json index 4c29d50..9427cb5 100644 --- a/src/main/resources/assets/skysleylines/lang/en_gb.json +++ b/src/main/resources/assets/skysleylines/lang/en_gb.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Sets the default full refill time in ticks when a world first initializes its ley profile. A Minecraft day is 24,000 ticks.", "config.skysleylines.rechargeTicksOverride": "Recharge Override", "config.skysleylines.rechargeTicksOverride.tooltip": "Enter 0 to use the saved base rate, or 1 to 1,728,000 ticks. Reopen the world to apply. Worlds using the instance server config share this setting.", + "config.skysleylines.nexusCapacityMultiplier": "Nexus Capacity Multiplier", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Scales the raw capacity of each nexus. The default 0.01 gives one hundredth of the base capacity. Reopen the world to apply.", "config.skysleylines.defaultVisionStyle": "Default Ley Vision Style", "config.skysleylines.defaultVisionStyle.tooltip": "Sets the appearance of operator vision and effects that use the default style. Worlds using the instance server config share this setting.", "config.skysleylines.style.river": "River", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": " %s units · %s units per raw", "meter.skysleylines.currency_inactive": " %s units · %s units per raw · Inactive", "meter.skysleylines.more": "%s more currencies. See /skysleylines account.", - "command.skysleylines.profile": "Saved ley layout v%s, Overworld only: %s; effectiveRechargeTicks=%s", + "command.skysleylines.profile": "Saved ley layout v%s, Overworld only: %s; effectiveRechargeTicks=%s nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Ley %s: active=%s strength=%s line=%s nexus=%s vertical=%s resistance=%s reserve=%s", "command.skysleylines.sample.unavailable": "unavailable", "command.skysleylines.locate.overworld": "Ley nexuses can currently be located in the Overworld.", diff --git a/src/main/resources/assets/skysleylines/lang/en_pt.json b/src/main/resources/assets/skysleylines/lang/en_pt.json index 4c29d50..9427cb5 100644 --- a/src/main/resources/assets/skysleylines/lang/en_pt.json +++ b/src/main/resources/assets/skysleylines/lang/en_pt.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Sets the default full refill time in ticks when a world first initializes its ley profile. A Minecraft day is 24,000 ticks.", "config.skysleylines.rechargeTicksOverride": "Recharge Override", "config.skysleylines.rechargeTicksOverride.tooltip": "Enter 0 to use the saved base rate, or 1 to 1,728,000 ticks. Reopen the world to apply. Worlds using the instance server config share this setting.", + "config.skysleylines.nexusCapacityMultiplier": "Nexus Capacity Multiplier", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Scales the raw capacity of each nexus. The default 0.01 gives one hundredth of the base capacity. Reopen the world to apply.", "config.skysleylines.defaultVisionStyle": "Default Ley Vision Style", "config.skysleylines.defaultVisionStyle.tooltip": "Sets the appearance of operator vision and effects that use the default style. Worlds using the instance server config share this setting.", "config.skysleylines.style.river": "River", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": " %s units · %s units per raw", "meter.skysleylines.currency_inactive": " %s units · %s units per raw · Inactive", "meter.skysleylines.more": "%s more currencies. See /skysleylines account.", - "command.skysleylines.profile": "Saved ley layout v%s, Overworld only: %s; effectiveRechargeTicks=%s", + "command.skysleylines.profile": "Saved ley layout v%s, Overworld only: %s; effectiveRechargeTicks=%s nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Ley %s: active=%s strength=%s line=%s nexus=%s vertical=%s resistance=%s reserve=%s", "command.skysleylines.sample.unavailable": "unavailable", "command.skysleylines.locate.overworld": "Ley nexuses can currently be located in the Overworld.", diff --git a/src/main/resources/assets/skysleylines/lang/en_us.json b/src/main/resources/assets/skysleylines/lang/en_us.json index 4c29d50..9427cb5 100644 --- a/src/main/resources/assets/skysleylines/lang/en_us.json +++ b/src/main/resources/assets/skysleylines/lang/en_us.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Sets the default full refill time in ticks when a world first initializes its ley profile. A Minecraft day is 24,000 ticks.", "config.skysleylines.rechargeTicksOverride": "Recharge Override", "config.skysleylines.rechargeTicksOverride.tooltip": "Enter 0 to use the saved base rate, or 1 to 1,728,000 ticks. Reopen the world to apply. Worlds using the instance server config share this setting.", + "config.skysleylines.nexusCapacityMultiplier": "Nexus Capacity Multiplier", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Scales the raw capacity of each nexus. The default 0.01 gives one hundredth of the base capacity. Reopen the world to apply.", "config.skysleylines.defaultVisionStyle": "Default Ley Vision Style", "config.skysleylines.defaultVisionStyle.tooltip": "Sets the appearance of operator vision and effects that use the default style. Worlds using the instance server config share this setting.", "config.skysleylines.style.river": "River", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": " %s units · %s units per raw", "meter.skysleylines.currency_inactive": " %s units · %s units per raw · Inactive", "meter.skysleylines.more": "%s more currencies. See /skysleylines account.", - "command.skysleylines.profile": "Saved ley layout v%s, Overworld only: %s; effectiveRechargeTicks=%s", + "command.skysleylines.profile": "Saved ley layout v%s, Overworld only: %s; effectiveRechargeTicks=%s nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Ley %s: active=%s strength=%s line=%s nexus=%s vertical=%s resistance=%s reserve=%s", "command.skysleylines.sample.unavailable": "unavailable", "command.skysleylines.locate.overworld": "Ley nexuses can currently be located in the Overworld.", diff --git a/src/main/resources/assets/skysleylines/lang/es_es.json b/src/main/resources/assets/skysleylines/lang/es_es.json index 8fcbb92..1ebb1bc 100644 --- a/src/main/resources/assets/skysleylines/lang/es_es.json +++ b/src/main/resources/assets/skysleylines/lang/es_es.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Tiempo predeterminado para una recarga completa al crear el perfil ley. Un día de Minecraft son 24 000 ticks.", "config.skysleylines.rechargeTicksOverride": "Tiempo de recarga personalizado", "config.skysleylines.rechargeTicksOverride.tooltip": "Usa 0 para mantener el tiempo base guardado, o de 1 a 1 728 000 ticks. Vuelve a abrir el mundo. Los mundos que usan la misma configuración del servidor comparten este ajuste.", + "config.skysleylines.nexusCapacityMultiplier": "Multiplicador de capacidad de nexo", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Ajusta la capacidad de magia bruta de cada nexo. El valor predeterminado 0.01 deja una centésima parte de la capacidad base. Vuelve a abrir el mundo.", "config.skysleylines.defaultVisionStyle": "Estilo de visión ley predeterminado", "config.skysleylines.defaultVisionStyle.tooltip": "Aspecto de la visión de operadores y de los efectos que usan el estilo predeterminado. Los mundos que usan la misma configuración del servidor comparten este ajuste.", "config.skysleylines.style.river": "Río", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": "%s unidades · %s unidades por magia bruta", "meter.skysleylines.currency_inactive": "%s unidades · %s unidades por magia bruta · Inactiva", "meter.skysleylines.more": "%s monedas más. Consulta /skysleylines account.", - "command.skysleylines.profile": "Trazado ley guardado v%s, solo mundo principal: %s. Recarga efectiva: %s ticks.", + "command.skysleylines.profile": "Trazado ley guardado v%s, solo mundo principal: %s. Recarga efectiva: %s ticks. nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Campo ley %s: activo=%s fuerza=%s línea=%s nexo=%s altura=%s resistencia=%s reserva=%s", "command.skysleylines.sample.unavailable": "no disponible", "command.skysleylines.locate.overworld": "Los nexos ley se pueden localizar en el mundo principal.", diff --git a/src/main/resources/assets/skysleylines/lang/es_mx.json b/src/main/resources/assets/skysleylines/lang/es_mx.json index 6fea575..e39ada3 100644 --- a/src/main/resources/assets/skysleylines/lang/es_mx.json +++ b/src/main/resources/assets/skysleylines/lang/es_mx.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Tiempo predeterminado para una recarga completa al crear el perfil ley. Un día de Minecraft son 24 000 ticks.", "config.skysleylines.rechargeTicksOverride": "Tiempo de recarga personalizado", "config.skysleylines.rechargeTicksOverride.tooltip": "Usa 0 para mantener el tiempo base guardado, o de 1 a 1 728 000 ticks. Vuelve a abrir el mundo. Los mundos que usan la misma configuración del servidor comparten este ajuste.", + "config.skysleylines.nexusCapacityMultiplier": "Multiplicador de capacidad de nexo", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Ajusta la capacidad de magia bruta de cada nexo. El valor predeterminado 0.01 deja una centésima parte de la capacidad base. Vuelve a abrir el mundo.", "config.skysleylines.defaultVisionStyle": "Estilo de visión ley predeterminado", "config.skysleylines.defaultVisionStyle.tooltip": "Aspecto de la visión de operadores y de los efectos que usan el estilo predeterminado. Los mundos que usan la misma configuración del servidor comparten este ajuste.", "config.skysleylines.style.river": "Río", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": "%s unidades · %s unidades por magia bruta", "meter.skysleylines.currency_inactive": "%s unidades · %s unidades por magia bruta · Inactiva", "meter.skysleylines.more": "%s monedas más. Consulta /skysleylines account.", - "command.skysleylines.profile": "Trazado ley guardado v%s, solo mundo principal: %s. Recarga efectiva: %s ticks.", + "command.skysleylines.profile": "Trazado ley guardado v%s, solo mundo principal: %s. Recarga efectiva: %s ticks. nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Campo ley %s: activo=%s fuerza=%s línea=%s nexo=%s altura=%s resistencia=%s reserva=%s", "command.skysleylines.sample.unavailable": "no disponible", "command.skysleylines.locate.overworld": "Los nexos ley se pueden localizar en el mundo principal.", diff --git a/src/main/resources/assets/skysleylines/lang/fr_ca.json b/src/main/resources/assets/skysleylines/lang/fr_ca.json index a542de8..c649941 100644 --- a/src/main/resources/assets/skysleylines/lang/fr_ca.json +++ b/src/main/resources/assets/skysleylines/lang/fr_ca.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Temps de recharge complète par défaut à la création du profil tellurique. Un jour Minecraft dure 24 000 ticks.", "config.skysleylines.rechargeTicksOverride": "Durée de recharge personnalisée", "config.skysleylines.rechargeTicksOverride.tooltip": "Entrez 0 pour garder la durée de base enregistrée, ou de 1 à 1 728 000 ticks. Rouvrez le monde. Les mondes qui utilisent la même configuration serveur partagent ce réglage.", + "config.skysleylines.nexusCapacityMultiplier": "Multiplicateur de capacité des nexus", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Ajuste la capacité en magie brute de chaque nexus. La valeur par défaut 0.01 donne un centième de la capacité de base. Rouvrez le monde.", "config.skysleylines.defaultVisionStyle": "Style de vision tellurique par défaut", "config.skysleylines.defaultVisionStyle.tooltip": "Apparence de la vision des opérateurs et des effets qui utilisent le style par défaut. Les mondes qui utilisent la même configuration serveur partagent ce réglage.", "config.skysleylines.style.river": "Rivière", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": "%s unités · %s unités par magie brute", "meter.skysleylines.currency_inactive": "%s unités · %s unités par magie brute · Inactive", "meter.skysleylines.more": "%s monnaies de plus. Voir /skysleylines account.", - "command.skysleylines.profile": "Réseau tellurique enregistré v%s, monde normal uniquement : %s. Recharge effective : %s ticks.", + "command.skysleylines.profile": "Réseau tellurique enregistré v%s, monde normal uniquement : %s. Recharge effective : %s ticks. nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Champ %s : actif=%s force=%s ligne=%s nexus=%s facteur vertical=%s résistance=%s réserve=%s", "command.skysleylines.sample.unavailable": "indisponible", "command.skysleylines.locate.overworld": "La recherche de nexus telluriques se fait dans le monde normal.", diff --git a/src/main/resources/assets/skysleylines/lang/fr_fr.json b/src/main/resources/assets/skysleylines/lang/fr_fr.json index 5d3a841..5315a14 100644 --- a/src/main/resources/assets/skysleylines/lang/fr_fr.json +++ b/src/main/resources/assets/skysleylines/lang/fr_fr.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Temps de recharge complète par défaut à la création du profil tellurique. Un jour Minecraft dure 24 000 ticks.", "config.skysleylines.rechargeTicksOverride": "Durée de recharge personnalisée", "config.skysleylines.rechargeTicksOverride.tooltip": "Saisissez 0 pour garder la durée de base enregistrée, ou de 1 à 1 728 000 ticks. Rouvrez le monde. Les mondes qui utilisent la même configuration serveur partagent ce réglage.", + "config.skysleylines.nexusCapacityMultiplier": "Multiplicateur de capacité des nexus", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Ajuste la capacité en magie brute de chaque nexus. La valeur par défaut 0.01 donne un centième de la capacité de base. Rouvrez le monde.", "config.skysleylines.defaultVisionStyle": "Style de vision tellurique par défaut", "config.skysleylines.defaultVisionStyle.tooltip": "Apparence de la vision des opérateurs et des effets qui utilisent le style par défaut. Les mondes qui utilisent la même configuration serveur partagent ce réglage.", "config.skysleylines.style.river": "Rivière", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": "%s unités · %s unités par magie brute", "meter.skysleylines.currency_inactive": "%s unités · %s unités par magie brute · Inactive", "meter.skysleylines.more": "%s monnaies de plus. Voir /skysleylines account.", - "command.skysleylines.profile": "Réseau tellurique enregistré v%s, monde normal uniquement : %s. Recharge effective : %s ticks.", + "command.skysleylines.profile": "Réseau tellurique enregistré v%s, monde normal uniquement : %s. Recharge effective : %s ticks. nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Champ %s : actif=%s force=%s ligne=%s nexus=%s facteur vertical=%s résistance=%s réserve=%s", "command.skysleylines.sample.unavailable": "indisponible", "command.skysleylines.locate.overworld": "La recherche de nexus telluriques se fait dans le monde normal.", diff --git a/src/main/resources/assets/skysleylines/lang/it_it.json b/src/main/resources/assets/skysleylines/lang/it_it.json index 754b8ea..b243417 100644 --- a/src/main/resources/assets/skysleylines/lang/it_it.json +++ b/src/main/resources/assets/skysleylines/lang/it_it.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Tempo di ricarica completa predefinito alla creazione del profilo ley. Un giorno in Minecraft dura 24.000 tick.", "config.skysleylines.rechargeTicksOverride": "Tempo di ricarica personalizzato", "config.skysleylines.rechargeTicksOverride.tooltip": "Usa 0 per il tempo base salvato o un valore da 1 a 1.728.000 tick. Riapri il mondo. I mondi che usano la stessa configurazione del server condividono questa impostazione.", + "config.skysleylines.nexusCapacityMultiplier": "Moltiplicatore della capacità dei nexus", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Modifica la capacità di magia grezza di ogni nexus. Il valore predefinito 0.01 corrisponde a un centesimo della capacità di base. Riapri il mondo.", "config.skysleylines.defaultVisionStyle": "Stile predefinito della visione ley", "config.skysleylines.defaultVisionStyle.tooltip": "Aspetto della visione degli operatori e degli effetti con lo stile predefinito. I mondi che usano la stessa configurazione del server condividono questa impostazione.", "config.skysleylines.style.river": "Fiume", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": "%s unità · %s unità per magia grezza", "meter.skysleylines.currency_inactive": "%s unità · %s unità per magia grezza · Inattiva", "meter.skysleylines.more": "Altre %s valute. Vedi /skysleylines account.", - "command.skysleylines.profile": "Tracciato ley salvato v%s, solo nel mondo principale: %s. Ricarica effettiva: %s tick.", + "command.skysleylines.profile": "Tracciato ley salvato v%s, solo nel mondo principale: %s. Ricarica effettiva: %s tick. nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Campo ley %s: attivo=%s forza=%s linea=%s nodo=%s altezza=%s resistenza=%s riserva=%s", "command.skysleylines.sample.unavailable": "non disponibile", "command.skysleylines.locate.overworld": "I nodi ley si possono trovare nel mondo principale.", diff --git a/src/main/resources/assets/skysleylines/lang/ja_jp.json b/src/main/resources/assets/skysleylines/lang/ja_jp.json index ab7df46..53292d2 100644 --- a/src/main/resources/assets/skysleylines/lang/ja_jp.json +++ b/src/main/resources/assets/skysleylines/lang/ja_jp.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "レイプロファイル作成時に設定する全回復の既定時間です。Minecraft の1日は24,000ティックです。", "config.skysleylines.rechargeTicksOverride": "回復時間の上書き", "config.skysleylines.rechargeTicksOverride.tooltip": "0で保存済みの基本時間を使用します。1~1,728,000ティックを指定できます。ワールドを開き直すと反映されます。同じサーバー設定を使うワールドで共有されます。", + "config.skysleylines.nexusCapacityMultiplier": "ネクサス容量倍率", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "各ネクサスの生魔力容量に適用する倍率です。既定値の 0.01 では基本容量の 100 分の 1 になります。ワールドを開き直すと反映されます。", "config.skysleylines.defaultVisionStyle": "既定のレイ視覚スタイル", "config.skysleylines.defaultVisionStyle.tooltip": "オペレーターのレイ視覚と既定スタイルを使う効果の見た目です。同じサーバー設定を使うワールドで共有されます。", "config.skysleylines.style.river": "奔流", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": "%s単位 · 未変換魔力1あたり%s単位", "meter.skysleylines.currency_inactive": "%s単位 · 未変換魔力1あたり%s単位 · 無効", "meter.skysleylines.more": "ほか%s種類の通貨があります。/skysleylines account を参照してください。", - "command.skysleylines.profile": "保存済みレイ配置 v%s(オーバーワールドのみ):%s。有効回復時間:%sティック。", + "command.skysleylines.profile": "保存済みレイ配置 v%s(オーバーワールドのみ):%s。有効回復時間:%sティック。 nexusCapacityMultiplier=%s", "command.skysleylines.sample": "レイフィールド %s:有効=%s 強度=%s ライン=%s ネクサス=%s 垂直=%s 抵抗=%s 残量=%s", "command.skysleylines.sample.unavailable": "利用不可", "command.skysleylines.locate.overworld": "レイネクサスを検索できるのはオーバーワールドです。", diff --git a/src/main/resources/assets/skysleylines/lang/nl_nl.json b/src/main/resources/assets/skysleylines/lang/nl_nl.json index 28b02b8..6bb199d 100644 --- a/src/main/resources/assets/skysleylines/lang/nl_nl.json +++ b/src/main/resources/assets/skysleylines/lang/nl_nl.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Standaardtijd voor volledig opladen bij het aanmaken van het leyprofiel. Een Minecraft-dag duurt 24.000 ticks.", "config.skysleylines.rechargeTicksOverride": "Aangepaste oplaadtijd", "config.skysleylines.rechargeTicksOverride.tooltip": "Gebruik 0 voor de opgeslagen basistijd, of 1 tot 1.728.000 ticks. Open de wereld opnieuw. Werelden die dezelfde serverconfiguratie gebruiken delen deze instelling.", + "config.skysleylines.nexusCapacityMultiplier": "Vermenigvuldiger voor nexuscapaciteit", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Past de capaciteit voor ruwe magie van elke nexus aan. De standaardwaarde 0.01 geeft een honderdste van de basiscapaciteit. Open de wereld opnieuw.", "config.skysleylines.defaultVisionStyle": "Standaardstijl voor leyzicht", "config.skysleylines.defaultVisionStyle.tooltip": "Uiterlijk van leyzicht voor beheerders en effecten met de standaardstijl. Werelden die dezelfde serverconfiguratie gebruiken delen deze instelling.", "config.skysleylines.style.river": "Rivier", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": "%s eenheden · %s eenheden per ruwe magie", "meter.skysleylines.currency_inactive": "%s eenheden · %s eenheden per ruwe magie · Inactief", "meter.skysleylines.more": "Nog %s valuta. Zie /skysleylines account.", - "command.skysleylines.profile": "Opgeslagen leynetwerk v%s, alleen bovenwereld: %s. Effectieve oplaadtijd: %s ticks.", + "command.skysleylines.profile": "Opgeslagen leynetwerk v%s, alleen bovenwereld: %s. Effectieve oplaadtijd: %s ticks. nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Leyveld %s: actief=%s sterkte=%s lijn=%s nexus=%s hoogte=%s weerstand=%s voorraad=%s", "command.skysleylines.sample.unavailable": "niet beschikbaar", "command.skysleylines.locate.overworld": "Ley-nexussen zijn te vinden in de bovenwereld.", diff --git a/src/main/resources/assets/skysleylines/lang/pt_br.json b/src/main/resources/assets/skysleylines/lang/pt_br.json index cf59c68..941705d 100644 --- a/src/main/resources/assets/skysleylines/lang/pt_br.json +++ b/src/main/resources/assets/skysleylines/lang/pt_br.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Tempo padrão de recarga completa ao criar o perfil ley. Um dia do Minecraft dura 24.000 ticks.", "config.skysleylines.rechargeTicksOverride": "Tempo de recarga personalizado", "config.skysleylines.rechargeTicksOverride.tooltip": "Use 0 para manter o tempo base salvo, ou de 1 a 1.728.000 ticks. Reabra o mundo. Mundos que usam a mesma configuração do servidor compartilham esse ajuste.", + "config.skysleylines.nexusCapacityMultiplier": "Multiplicador de capacidade dos nexos", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Ajusta a capacidade de magia bruta de cada nexo. O valor padrão 0.01 dá um centésimo da capacidade base. Reabra o mundo para aplicar.", "config.skysleylines.defaultVisionStyle": "Estilo padrão da visão ley", "config.skysleylines.defaultVisionStyle.tooltip": "Aparência da visão de operadores e dos efeitos que usam o estilo padrão. Mundos que usam a mesma configuração do servidor compartilham esse ajuste.", "config.skysleylines.style.river": "Rio", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": "%s unidades · %s unidades por magia bruta", "meter.skysleylines.currency_inactive": "%s unidades · %s unidades por magia bruta · Inativa", "meter.skysleylines.more": "Mais %s moedas. Consulte /skysleylines account.", - "command.skysleylines.profile": "Traçado ley salvo v%s, somente no mundo superior: %s. Recarga efetiva: %s ticks.", + "command.skysleylines.profile": "Traçado ley salvo v%s, somente no mundo superior: %s. Recarga efetiva: %s ticks. nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Campo ley %s: ativo=%s força=%s linha=%s nexo=%s altura=%s resistência=%s reserva=%s", "command.skysleylines.sample.unavailable": "indisponível", "command.skysleylines.locate.overworld": "Os nexos ley podem ser localizados no mundo superior.", diff --git a/src/main/resources/assets/skysleylines/lang/ru_ru.json b/src/main/resources/assets/skysleylines/lang/ru_ru.json index ce3e32b..518ba62 100644 --- a/src/main/resources/assets/skysleylines/lang/ru_ru.json +++ b/src/main/resources/assets/skysleylines/lang/ru_ru.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "Время полной зарядки по умолчанию при создании лей-профиля. Сутки в Minecraft длятся 24 000 тиков.", "config.skysleylines.rechargeTicksOverride": "Особое время зарядки", "config.skysleylines.rechargeTicksOverride.tooltip": "Укажите 0 для сохранённого базового времени или от 1 до 1 728 000 тиков. Заново откройте мир. Миры с одним файлом настроек сервера используют это значение совместно.", + "config.skysleylines.nexusCapacityMultiplier": "Множитель ёмкости узлов", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "Масштабирует ёмкость каждого узла для сырой магии. Значение по умолчанию 0.01 даёт одну сотую от базовой ёмкости. Заново откройте мир.", "config.skysleylines.defaultVisionStyle": "Стиль лей-зрения по умолчанию", "config.skysleylines.defaultVisionStyle.tooltip": "Вид лей-зрения оператора и эффектов со стилем по умолчанию. Миры с одним файлом настроек сервера используют это значение совместно.", "config.skysleylines.style.river": "Поток", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": "%s ед. · %s ед. на единицу сырой магии", "meter.skysleylines.currency_inactive": "%s ед. · %s ед. на единицу сырой магии · Неактивна", "meter.skysleylines.more": "Другие валюты: %s. См. /skysleylines account.", - "command.skysleylines.profile": "Сохранённая лей-сеть v%s, только Верхний мир: %s. Действующее время зарядки: %s тиков.", + "command.skysleylines.profile": "Сохранённая лей-сеть v%s, только Верхний мир: %s. Действующее время зарядки: %s тиков. nexusCapacityMultiplier=%s", "command.skysleylines.sample": "Лей-поле %s: активно=%s сила=%s линия=%s узел=%s высота=%s сопротивление=%s запас=%s", "command.skysleylines.sample.unavailable": "недоступно", "command.skysleylines.locate.overworld": "Поиск лей-узлов доступен в Верхнем мире.", diff --git a/src/main/resources/assets/skysleylines/lang/zh_cn.json b/src/main/resources/assets/skysleylines/lang/zh_cn.json index a1ccd18..060d0ec 100644 --- a/src/main/resources/assets/skysleylines/lang/zh_cn.json +++ b/src/main/resources/assets/skysleylines/lang/zh_cn.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "创建地脉档案时设定的默认完全恢复时间。Minecraft 的一天为 24,000 游戏刻。", "config.skysleylines.rechargeTicksOverride": "自定义恢复时间", "config.skysleylines.rechargeTicksOverride.tooltip": "输入 0 使用已保存的基础时间,或输入 1 至 1,728,000 游戏刻。重新进入世界后生效。使用同一服务器配置的世界共享此设置。", + "config.skysleylines.nexusCapacityMultiplier": "节点容量倍率", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "调整每个节点的原始魔力容量。默认值 0.01 为基础容量的百分之一。重新进入世界后生效。", "config.skysleylines.defaultVisionStyle": "默认地脉视野样式", "config.skysleylines.defaultVisionStyle.tooltip": "决定管理员视野以及使用默认样式的效果外观。使用同一服务器配置的世界共享此设置。", "config.skysleylines.style.river": "奔流", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": "%s 单位 · 每单位原始魔力兑换 %s 单位", "meter.skysleylines.currency_inactive": "%s 单位 · 每单位原始魔力兑换 %s 单位 · 已停用", "meter.skysleylines.more": "另有 %s 种货币。请查看 /skysleylines account。", - "command.skysleylines.profile": "已保存的地脉布局 v%s(仅主世界):%s。生效的恢复时间:%s 游戏刻。", + "command.skysleylines.profile": "已保存的地脉布局 v%s(仅主世界):%s。生效的恢复时间:%s 游戏刻。 nexusCapacityMultiplier=%s", "command.skysleylines.sample": "地脉场 %s:激活=%s 强度=%s 地脉=%s 节点=%s 垂直=%s 阻力=%s 储备=%s", "command.skysleylines.sample.unavailable": "不可用", "command.skysleylines.locate.overworld": "目前可在主世界定位地脉节点。", diff --git a/src/main/resources/assets/skysleylines/lang/zh_tw.json b/src/main/resources/assets/skysleylines/lang/zh_tw.json index b0affa2..1a3a103 100644 --- a/src/main/resources/assets/skysleylines/lang/zh_tw.json +++ b/src/main/resources/assets/skysleylines/lang/zh_tw.json @@ -18,6 +18,8 @@ "config.skysleylines.rechargeTicks.tooltip": "建立地脈設定檔時設定的預設完全恢復時間。Minecraft 的一天為 24,000 遊戲刻。", "config.skysleylines.rechargeTicksOverride": "自訂恢復時間", "config.skysleylines.rechargeTicksOverride.tooltip": "輸入 0 使用已儲存的基礎時間,或輸入 1 至 1,728,000 遊戲刻。重新開啟世界後生效。使用相同伺服器設定檔的世界共用此設定。", + "config.skysleylines.nexusCapacityMultiplier": "節點容量倍率", + "config.skysleylines.nexusCapacityMultiplier.tooltip": "調整每個節點的原始魔力容量。預設值 0.01 為基礎容量的百分之一。重新開啟世界後生效。", "config.skysleylines.defaultVisionStyle": "預設地脈視野樣式", "config.skysleylines.defaultVisionStyle.tooltip": "決定管理員視野以及使用預設樣式的效果外觀。使用相同伺服器設定檔的世界共用此設定。", "config.skysleylines.style.river": "奔流", @@ -69,7 +71,7 @@ "meter.skysleylines.currency": "%s 單位 · 每單位原始魔力兌換 %s 單位", "meter.skysleylines.currency_inactive": "%s 單位 · 每單位原始魔力兌換 %s 單位 · 已停用", "meter.skysleylines.more": "另有 %s 種貨幣。請查看 /skysleylines account。", - "command.skysleylines.profile": "已儲存的地脈布局 v%s(僅限主世界):%s。有效恢復時間:%s 遊戲刻。", + "command.skysleylines.profile": "已儲存的地脈布局 v%s(僅限主世界):%s。有效恢復時間:%s 遊戲刻。 nexusCapacityMultiplier=%s", "command.skysleylines.sample": "地脈場 %s:啟用=%s 強度=%s 地脈=%s 節點=%s 垂直=%s 阻力=%s 儲備=%s", "command.skysleylines.sample.unavailable": "無法取得", "command.skysleylines.locate.overworld": "目前可在主世界定位地脈節點。", diff --git a/src/runtimeProbe/java/zone/moddev/mc/skysleylines/probe/LeyRuntimeProbe.java b/src/runtimeProbe/java/zone/moddev/mc/skysleylines/probe/LeyRuntimeProbe.java index 80f785e..eea24a2 100644 --- a/src/runtimeProbe/java/zone/moddev/mc/skysleylines/probe/LeyRuntimeProbe.java +++ b/src/runtimeProbe/java/zone/moddev/mc/skysleylines/probe/LeyRuntimeProbe.java @@ -51,6 +51,7 @@ else if(current!=null) { }); NeoForge.EVENT_BUS.addListener((NexusTransactionEvent.Post e)->posts++); NeoForge.EVENT_BUS.addListener(LeyRuntimeProbe::started); + NeoForge.EVENT_BUS.addListener(PlayerEnergyProbe::run); NeoForge.EVENT_BUS.addListener(LeyRuntimeProbe::commands); } private static void commands(RegisterCommandsEvent event) { diff --git a/src/runtimeProbe/java/zone/moddev/mc/skysleylines/probe/PlayerEnergyProbe.java b/src/runtimeProbe/java/zone/moddev/mc/skysleylines/probe/PlayerEnergyProbe.java new file mode 100644 index 0000000..c612b82 --- /dev/null +++ b/src/runtimeProbe/java/zone/moddev/mc/skysleylines/probe/PlayerEnergyProbe.java @@ -0,0 +1,61 @@ +package zone.moddev.mc.skysleylines.probe; + +import java.util.UUID; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.server.ServerStartedEvent; +import zone.moddev.mc.skysleylines.api.*; +import zone.moddev.mc.skysleylines.api.PlayerPowerService.Settings; + +final class PlayerEnergyProbe { + private static final ResourceLocation AETHER = ResourceLocation.parse("leyruntimeprobe:aether"); + private static final PowerRequest.Consumer ACCEPT = source("player_probe"); + private static final PowerRequest.Consumer DENY = source("deny_player_probe"); + + private PlayerEnergyProbe() {} + + static void run(ServerStartedEvent event) { + if (!Boolean.getBoolean("skysleylines.playerEnergyProbe")) return; + var server = event.getServer(); + var field = LeyLinesApi.forLevel(server.overworld()); + var nexus = field.field().nearestNexus(0, 0, 4096).orElseThrow(); + int x = (int) Math.round(nexus.x()), z = (int) Math.round(nexus.z()); + int y = field.field().sampleColumn(x, z).naturalSurfaceY(); + var node = field.power(); + var power = LeyLinesApi.playerPower(server); + var player = UUID.randomUUID(); + int[] checks = {0}; + NeoForge.EVENT_BUS.addListener((PlayerPowerEvent.Pre pre) -> { + if (!pre.receipt().consumer().equals(DENY)) return; + require(node.convertAt(x, y, z, AETHER, MagicAmount.ONE, ACCEPT).status() + == PowerResult.Status.REENTRANT, "Nested node write from wallet Pre"); + require(power.setBalance(player, AETHER, 1, ACCEPT).status() + == PlayerPowerService.Status.REENTRANT, "Nested wallet write from wallet Pre"); + checks[0]++; + pre.setCanceled(true); + }); + NeoForge.EVENT_BUS.addListener((NexusTransactionEvent.Pre pre) -> { + if (!pre.proposed().request().consumer().equals(ACCEPT)) return; + require(power.setBalance(player, AETHER, 1, ACCEPT).status() + == PlayerPowerService.Status.REENTRANT, "Nested wallet write from nexus Pre"); + checks[0]++; + }); + require(power.configure(player, AETHER, new Settings(true, 4, null), ACCEPT).succeeded(), "Pool configure failed"); + require(power.setBalance(player, AETHER, 4, ACCEPT).succeeded(), "Pool credit failed"); + var before = node.quoteAt(x, y, z).orElseThrow().account(); + require(power.spend(player, AETHER, 4, DENY).status() == PlayerPowerService.Status.CANCELLED, "Pool Pre cancellation ignored"); + require(power.inspect(player).pools().get(AETHER).balance() == 4, "Cancelled spend changed the pool"); + require(node.quoteAt(x, y, z).orElseThrow().account().equals(before), "Nested write changed nexus"); + require(node.convertAt(x, y, z, AETHER, MagicAmount.ONE, ACCEPT).succeeded(), "Outer nexus write failed"); + require(power.inspect(player).pools().get(AETHER).balance() == 4 && checks[0] == 2, "Protection checks were skipped"); + System.out.println("PLAYER_ENERGY_JAVA_PROTECTION_PASS"); + } + + private static PowerRequest.Consumer source(String path) { + return new PowerRequest.Consumer(ResourceLocation.fromNamespaceAndPath("leyruntimeprobe", path)); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalStateException(message); + } +} diff --git a/src/runtimeProbe/resources/META-INF/neoforge.mods.toml b/src/runtimeProbe/resources/META-INF/neoforge.mods.toml index 9e8dbc2..c63c273 100644 --- a/src/runtimeProbe/resources/META-INF/neoforge.mods.toml +++ b/src/runtimeProbe/resources/META-INF/neoforge.mods.toml @@ -8,6 +8,6 @@ displayName="Ley Lines disposable runtime consumer" [[dependencies.leyruntimeprobe]] modId="skysleylines" type="required" -versionRange="[0.5.4.121012]" +versionRange="[0.5.5.121012]" ordering="AFTER" side="BOTH" diff --git a/src/test/java/zone/moddev/mc/skysleylines/LeyWorldDataTest.java b/src/test/java/zone/moddev/mc/skysleylines/LeyWorldDataTest.java index 9f2b88d..51b6c68 100644 --- a/src/test/java/zone/moddev/mc/skysleylines/LeyWorldDataTest.java +++ b/src/test/java/zone/moddev/mc/skysleylines/LeyWorldDataTest.java @@ -15,6 +15,53 @@ import zone.moddev.mc.skysleylines.server.LeyWorldData; class LeyWorldDataTest { + @Test void capacityMultiplierPreservesConvertedFundsAndRechargeUsesTheNewBudget() { + var tick = new AtomicLong(100); + var data = new LeyWorldData(LeyProfile.DEFAULT, tick::get); + var node = NexusLedgerTest.NODE; + var currency = NexusLedgerTest.MANA; + data.configureCurrencies(Map.of(currency, MagicAmount.of(10))); + var oldLedger = data.ledger(); + int baseCapacity = 7000; + assertTrue(oldLedger.transact(node, baseCapacity, 100, + NexusLedgerTest.request(PowerRequest.Operation.CONVERT, currency, MagicAmount.of(80)), NexusLedger.NO_HOOKS).succeeded()); + var before = data.save(new CompoundTag(), null); + assertThrows(IllegalStateException.class, () -> data.applyPoolSettings(0, 100, () -> { + throw new IllegalStateException("test backup failure"); + })); + assertEquals(before, data.save(new CompoundTag(), null)); + data.applyPoolSettings(0, 100, () -> assertEquals(7000, oldLedger.quote(node, baseCapacity, 100).capacity())); + var scaled = data.save(new CompoundTag(), null); + assertEquals(6, scaled.getInt("schema")); + assertFalse(scaled.contains("pins")); + assertFalse(scaled.contains("playerEnergy")); + var loaded = LeyWorldData.load(scaled, tick::get); + assertEquals(LeyProfile.DEFAULT, loaded.profile()); + loaded.configureCurrencies(Map.of(currency, MagicAmount.of(10))); + var ledger = loaded.ledger(); + var overfilled = ledger.quote(node, baseCapacity, 1000); + assertEquals(70, overfilled.capacity()); + assertEquals(800, overfilled.currency(currency)); + assertEquals(MagicAmount.ZERO, overfilled.raw()); + assertTrue(ledger.transact(node, baseCapacity, 1000, + NexusLedgerTest.request(PowerRequest.Operation.RETURN_TO_RAW, currency, MagicAmount.of(50)), NexusLedger.NO_HOOKS).succeeded()); + assertEquals(MagicAmount.of(5), ledger.quote(node, baseCapacity, 2000).raw()); + assertTrue(ledger.transact(node, baseCapacity, 2000, + NexusLedgerTest.request(PowerRequest.Operation.SPEND, currency, MagicAmount.of(450)), NexusLedger.NO_HOOKS).succeeded()); + assertEquals(MagicAmount.fraction(45, 2), ledger.quote(node, baseCapacity, 8000).raw()); + assertEquals(MagicAmount.of(40), ledger.quote(node, baseCapacity, 14000).raw()); + assertEquals(MagicAmount.of(70), ledger.quote(node, baseCapacity, 14000).total()); + var untouched = new NexusId("minecraft:overworld", 0, 0, 0, 1); + assertEquals(MagicAmount.of(70), ledger.quote(untouched, baseCapacity, 14000).raw()); + tick.set(14000); + loaded.applyPoolSettings(0, NexusLedger.CAPACITY_SCALE_ONE, () -> {}); + var reset = loaded.save(new CompoundTag(), null); + assertEquals(6, reset.getInt("schema")); + var restored = LeyWorldData.load(reset, tick::get).ledger(); + assertEquals(7000, restored.quote(node, baseCapacity, 14000).capacity()); + assertEquals(MagicAmount.of(70), restored.quote(node, baseCapacity, 14000).total()); + } + @Test void compressedSaveAcceptsTheExactNbtLimitAndRejectsTheNextDigitBeforeBinding() throws Exception { var data = new LeyWorldData(LeyProfile.DEFAULT, () -> 0); var id = NexusLedgerTest.MANA; diff --git a/src/test/java/zone/moddev/mc/skysleylines/VisionTest.java b/src/test/java/zone/moddev/mc/skysleylines/VisionTest.java index fce924c..9f5148a 100644 --- a/src/test/java/zone/moddev/mc/skysleylines/VisionTest.java +++ b/src/test/java/zone/moddev/mc/skysleylines/VisionTest.java @@ -109,11 +109,17 @@ private static LeyNetwork field(LeyProfile profile, boolean active) { var dimension = ResourceLocation.parse("minecraft:overworld"); var style = new LeyVisionStyle(LeyVisionStyle.Pattern.SPARKLES, 0xffbb88, 0x8877ff, 0.65f, 0.8f); var payload = new VisionPayload(dimension, field(LeyProfile.DEFAULT, true).vision(1166, -743), style); + var authored = new VisionPayload(dimension, new VisionGeometry(32, + java.util.List.of(new VisionGeometry.Reach(-15, -14, -25, -34, 0.8f, -40, 77)), + java.util.List.of(new VisionGeometry.Crossing(-15, -14, 0.85f, -40))), style); var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), RegistryAccess.EMPTY); try { VisionPayload.CODEC.encode(buffer, payload); assertEquals(payload, VisionPayload.CODEC.decode(buffer)); assertFalse(buffer.isReadable()); + VisionPayload.CODEC.encode(buffer, authored); + assertEquals(authored, VisionPayload.CODEC.decode(buffer)); + assertFalse(buffer.isReadable()); buffer.clear(); buffer.writeResourceLocation(dimension); buffer.writeEnum(style.pattern()); diff --git a/src/test/java/zone/moddev/mc/skysleylines/server/NexusPinsTest.java b/src/test/java/zone/moddev/mc/skysleylines/server/NexusPinsTest.java new file mode 100644 index 0000000..d7238b8 --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysleylines/server/NexusPinsTest.java @@ -0,0 +1,163 @@ +package zone.moddev.mc.skysleylines.server; + +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; +import java.util.concurrent.atomic.AtomicInteger; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.ResourceLocation; +import org.junit.jupiter.api.Test; +import zone.moddev.mc.skysleylines.api.*; +import zone.moddev.mc.skysleylines.core.LeyNetwork; +import zone.moddev.mc.skysleylines.core.NexusLedger; +import static org.junit.jupiter.api.Assertions.*; +import static zone.moddev.mc.skysleylines.core.NexusLedger.RecoveryStatus.*; + +class NexusPinsTest { + private static final String DIM = NexusPins.DIMENSION; + private static final ResourceLocation PIN = ResourceLocation.parse("test:deep"); + private static final ResourceLocation CURRENCY = ResourceLocation.parse("test:aether"); + private static final PowerRequest.Consumer CONSUMER = new PowerRequest.Consumer(ResourceLocation.parse("test:recovery")); + + @Test void chunkQueryUsesHalfOpenBoundsAndNeverSamplesTerrain() { + var profile = new LeyProfile(257, 32, 64, 128, 10000, 24000); + var field = new LeyNetwork(44, DIM, profile, (x, z) -> fail("Chunk query touched height"), true); + int found = 0; + for (int x = -18; x <= 18; x++) for (int z = -18; z <= 18; z++) { + var sites = field.nexusesInChunk(x, z); + for (var site : sites) { + assertEquals(x, Math.floorDiv((int) Math.floor(site.x()), 16)); + assertEquals(z, Math.floorDiv((int) Math.floor(site.z()), 16)); + assertEquals(site, field.nearestNexus((int) Math.round(site.x()), (int) Math.round(site.z()), 1).orElseThrow()); + } + assertEquals(sites.size(), sites.stream().map(Nexus::id).distinct().count()); + assertEquals(sites, sites.stream().sorted(java.util.Comparator.comparing(Nexus::id)).toList()); + found += sites.size(); + } + assertTrue(found > 0); + } + + @Test void deepPinsShareChunkAndUseTheirOwnHeight() { + var one = pin(PIN, -16, -40, -1, NexusPins.State.ACTIVE); + var two = pin(ResourceLocation.parse("test:other"), -1, 40, -16, NexusPins.State.ACTIVE); + var baseline = new LeyNetwork(44, DIM, LeyProfile.DEFAULT, (x, z) -> OptionalInt.of(100), true); + var field = new LeyNetwork(44, DIM, LeyProfile.DEFAULT, (x, z) -> OptionalInt.of(100), true, List.of(two.nexus(), one.nexus())); + var chunk = field.nexusesInChunk(-1, -1); + assertTrue(chunk.containsAll(List.of(one.nexus(), two.nexus()))); + assertThrows(UnsupportedOperationException.class, () -> chunk.add(one.nexus())); + var deep = field.sample(-16, -40, -1); + assertEquals(one.nexus(), deep.nexus().orElseThrow()); + assertEquals(0.85, deep.nexusStrength(), 1e-12); + assertTrue(deep.canDrawPower()); + assertEquals(deep.naturalStrength(), field.sampleColumn(-16, -1).naturalStrengthAt(-40), 1e-12); + assertEquals(baseline.sample(3000, 100, -3000), field.sample(3000, 100, -3000)); + assertTrue(field.vision(-16, -1).crossings().stream().anyMatch(site -> site.y() == -40)); + } + + @Test void straddlingChunkUsesItsMarginOrFindsTheSiteInTheAdjacentTile() { + long seed = -4965128775892001975L; + var wide = new LeyNetwork(seed, DIM, new LeyProfile(257, 32, 64, 128, 10000, 24000), + (x, z) -> fail("Chunk query touched height"), true); + var narrow = new LeyNetwork(seed, DIM, new LeyProfile(257, 1, 1, 128, 10000, 24000), + (x, z) -> fail("Chunk query touched height"), true); + + // The chunk starts at z=256, and the crossing is beyond the narrow margin of tile zero. + var wideSites = wide.nexusesInChunk(-38, 16); + assertEquals(1, wide.cachedTileCount()); + assertTrue(wideSites.contains(wide.nearestNexus(-601, 261, 2).orElseThrow())); + var narrowSites = narrow.nexusesInChunk(-38, 16); + assertTrue(narrowSites.contains(narrow.nearestNexus(-601, 261, 2).orElseThrow())); + } + + @Test void aPinCanRemainIsolatedWhenNoSeededLineIsWithinReach() { + var profile = new LeyProfile(65536, 32, 64, 128, 10000, 24000); + var pin = pin(PIN, 0, -40, 0, NexusPins.State.ACTIVE).nexus(); + var field = new LeyNetwork(44, DIM, profile, (x, z) -> OptionalInt.of(100), true, List.of(pin)); + assertTrue(field.connections(pin).isEmpty()); + assertEquals(pin, field.sample(0, -40, 0).nexus().orElseThrow()); + assertTrue(field.vision(0, 0).reaches().isEmpty()); + } + + @Test void backupFailureDoesNotStageOrUpgradeAndSchemaFiveRetainsRetiredFunds() { + var data = new LeyWorldData(LeyProfile.DEFAULT, () -> 100); + data.configureCurrencies(Map.of(CURRENCY, MagicAmount.of(3))); + var pin = pin(PIN, 0, 20, 0, NexusPins.State.STAGED_ADD); + var before = data.save(new CompoundTag(), null); + assertThrows(IllegalStateException.class, () -> data.stagePin(pin)); + assertEquals(before, data.save(new CompoundTag(), null)); + assertTrue(data.activePins().isEmpty()); + + var staged = new NexusPins(); + staged.put(pin); + before.putInt("schema", 5); + before.putInt("effectiveRechargeTicks", data.profile().rechargeTicks()); + before.put("playerEnergy", new PlayerEnergyData().write()); + before.put("pins", staged.write()); + var loaded = LeyWorldData.load(before, () -> 100); + assertTrue(loaded.activePins().isEmpty()); + assertTrue(loaded.pins.activateStaged()); + assertEquals(List.of(pin.nexus()), loaded.activePins()); + var account = pin.nexus().id(); + var capacity = pin.nexus().capacity(loaded.profile()); + loaded.ledger().restore(account, new NexusLedger.Balance(capacity, MagicAmount.ONE, Map.of(CURRENCY, 2L), 0)); + loaded.stagePin(pin.state(NexusPins.State.STAGED_REMOVE)); + assertEquals(List.of(pin.nexus()), loaded.activePins()); + assertTrue(loaded.pins.activateStaged()); + var saved = loaded.save(new CompoundTag(), null); + var restored = LeyWorldData.load(saved, () -> 100); + assertTrue(restored.activePins().isEmpty()); + assertEquals(2, restored.ledger().entries().get(account).currencies().get(CURRENCY)); + assertThrows(IllegalArgumentException.class, () -> restored.pins.checkAdd(pin)); + restored.configureCurrencies(Map.of(CURRENCY, MagicAmount.of(4))); + assertThrows(IllegalStateException.class, restored::previewMigration); + assertEquals("test:deep", saved.getList("balances", Tag.TAG_COMPOUND).getCompound(0).getString("pin")); + restored.applyPoolSettings(0, 100, () -> {}); + var scaled = LeyWorldData.load(restored.save(new CompoundTag(), null), () -> 100); + assertTrue(scaled.activePins().isEmpty()); + assertEquals(NexusPins.State.RETIRED, scaled.pins.get(PIN).state()); + assertEquals(2, scaled.ledger().quote(account, capacity, 100).currency(CURRENCY)); + assertEquals(85, scaled.ledger().quote(account, capacity, 100).capacity()); + } + + @Test void recoveryMovesInactiveWholeUnitsExactlyAndCommitsBothOrNeither() { + var ledger = new NexusLedger(24000); + var pin = NexusId.pinned(DIM, PIN); + var dest = new NexusId(DIM, 0, 0, 0, 1); + ledger.restoreRate(CURRENCY, MagicAmount.of(3)); + ledger.restore(pin, new NexusLedger.Balance(10, MagicAmount.of(9), Map.of(CURRENCY, 3L), 0)); + ledger.restore(dest, new NexusLedger.Balance(10, MagicAmount.fraction(28, 3), Map.of(), 0)); + var before = ledger.entries(); + var posts = new AtomicInteger(); + class Hooks implements NexusLedger.RecoveryHooks { + boolean accept; + @Override public boolean before(NexusRecoveryEvent.Receipt receipt) { + assertEquals(2, receipt.destination().after().currency(CURRENCY)); + assertEquals(MagicAmount.of(10), receipt.destination().after().total()); + assertEquals(REENTRANT, move(ledger, pin, dest, 2, this)); + return accept; + } + @Override public void after(NexusRecoveryEvent.Receipt receipt) { posts.incrementAndGet(); } + @Override public void failure(RuntimeException failure) { fail(failure); } + } + var hooks = new Hooks(); + assertEquals(CAPACITY, move(ledger, pin, dest, 3, hooks)); + assertEquals(CANCELLED, move(ledger, pin, dest, 2, hooks)); + assertEquals(before, ledger.entries()); + hooks.accept = true; + assertEquals(SUCCESS, move(ledger, pin, dest, 2, hooks)); + assertEquals(1, posts.get()); + assertEquals(1, ledger.entries().get(pin).currencies().get(CURRENCY)); + assertEquals(2, ledger.entries().get(dest).currencies().get(CURRENCY)); + } + + private static NexusLedger.RecoveryStatus move(NexusLedger ledger, NexusId pin, NexusId dest, + long units, NexusLedger.RecoveryHooks hooks) { + return ledger.recover(pin, BlockPos.ZERO, dest, BlockPos.ZERO, 10, 0, CURRENCY, units, CONSUMER, hooks); + } + + private static NexusPins.Pin pin(ResourceLocation id, int x, int y, int z, NexusPins.State state) { + return new NexusPins.Pin(id, new BlockPos(x, y, z), 0.85, state); + } +} diff --git a/src/test/java/zone/moddev/mc/skysleylines/server/PlayerEnergyTest.java b/src/test/java/zone/moddev/mc/skysleylines/server/PlayerEnergyTest.java new file mode 100644 index 0000000..d3ceb08 --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysleylines/server/PlayerEnergyTest.java @@ -0,0 +1,97 @@ +package zone.moddev.mc.skysleylines.server; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Map; +import java.util.UUID; +import net.minecraft.nbt.*; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.phys.Vec3; +import org.junit.jupiter.api.Test; +import zone.moddev.mc.skysleylines.api.*; +import zone.moddev.mc.skysleylines.api.PlayerPowerService.Settings; +import zone.moddev.mc.skysleylines.core.NexusLedger; +import static org.junit.jupiter.api.Assertions.*; + +class PlayerEnergyTest { + private static final ResourceLocation AETHER = ResourceLocation.parse("test:aether"); + private static final ResourceLocation MANA = ResourceLocation.parse("test:mana"); + private static final NexusId NODE = new NexusId("minecraft:overworld", 0, 0, 1, 1); + private static final long EXACT = 9_007_199_254_740_993L; + + @Test void fractionalRatesAccumulateOnlyCompletedUnits() { + var total = MagicAmount.of(7); + var speed = MagicAmount.fraction(3, 2); + assertEquals(MagicAmount.ONE, EnergyJobs.earned(total, speed, 1, MagicAmount.ONE)); + assertEquals(MagicAmount.of(3), EnergyJobs.earned(total, speed, 2, MagicAmount.ONE)); + assertEquals(MagicAmount.of(4), EnergyJobs.earned(total, speed, 3, MagicAmount.ONE)); + assertEquals(total, EnergyJobs.earned(total, speed, 5, MagicAmount.ONE)); + + var rate = MagicAmount.fraction(3, 2); + var raw = MagicAmount.of(2); + var rawPerTick = MagicAmount.fraction(1, 2); + assertEquals(MagicAmount.ZERO, EnergyJobs.earned(raw, rawPerTick, 1, rate)); + assertEquals(MagicAmount.fraction(2, 3), EnergyJobs.earned(raw, rawPerTick, 2, rate)); + assertEquals(MagicAmount.fraction(4, 3), EnergyJobs.earned(raw, rawPerTick, 3, rate)); + assertEquals(raw, EnergyJobs.earned(raw, rawPerTick, 4, rate)); + } + + @Test void compressedSchemaFourRetainsExactDormantAndDroppedBalancesAndRecharge() throws Exception { + var data = new LeyWorldData(LeyProfile.DEFAULT, () -> 0); + data.configureCurrencies(Map.of(AETHER, MagicAmount.of(1000), MANA, MagicAmount.ONE)); + data.applyRechargeOverride(200, () -> {}); + var energy = new PlayerEnergyData(); + var player = UUID.randomUUID(); + var drop = UUID.randomUUID(); + energy.put(player, AETHER, new PlayerEnergyData.StoredPool(new Settings(false, 4, MagicAmount.fraction(1, 3)), EXACT)); + energy.put(player, MANA, new PlayerEnergyData.StoredPool(new Settings(true, 9, null), 0)); + energy.drops.put(drop, new PlayerEnergyData.Drop(player, ResourceLocation.parse("minecraft:the_nether"), + new Vec3(1.25, 74, -0.5), 6000, true, Map.of(AETHER, 100L, MANA, 1L))); + var tag = data.save(new CompoundTag(), null); + assertEquals(3, tag.getInt("schema")); + tag.putInt("schema", 4); + tag.put("playerEnergy", energy.write()); + var compressed = new ByteArrayOutputStream(); + NbtIo.writeCompressed(tag, compressed); + var restored = LeyWorldData.load(NbtIo.readCompressed(new ByteArrayInputStream(compressed.toByteArray()), + NbtAccounter.unlimitedHeap()), () -> 0); + restored.configureCurrencies(Map.of(MANA, MagicAmount.ONE)); + assertEquals(200, restored.ledger().rechargeTicks()); + assertFalse(restored.ledger().activeCurrencies().contains(AETHER)); + assertEquals(energy.players, restored.playerEnergy.players); + assertEquals(energy.drops, restored.playerEnergy.drops); + assertEquals(tag, restored.save(new CompoundTag(), null)); + } + + @Test void absentBackupCannotUpgradeTheSaveAndMalformedPlayerDataFailsClosed() { + var data = new LeyWorldData(LeyProfile.DEFAULT, () -> 0); + var tag = data.save(new CompoundTag(), null); + assertThrows(IllegalStateException.class, data::preparePlayerSave); + assertEquals(tag, data.save(new CompoundTag(), null)); + tag.putInt("schema", 4); + tag.putInt("effectiveRechargeTicks", data.ledger().rechargeTicks()); + assertThrows(IllegalArgumentException.class, () -> LeyWorldData.load(tag, () -> 0)); + } + + @Test void nodeDepositIsPreparedUnderTheSharedGuardAndRespectsExactCapacity() { + var ledger = new NexusLedger(24000); + var rate = MagicAmount.fraction(3, 2); + ledger.bindCurrencies(Map.of(AETHER, rate)); + ledger.restore(NODE, new NexusLedger.Balance(10, MagicAmount.of(8), Map.of(AETHER, 1L), 0)); + long revision = ledger.revision(); + assertTrue(ledger.exclusive(false, () -> { + assertEquals(PowerResult.Status.REENTRANT, ledger.transact(NODE, 10, 0, + new PowerRequest(0, 0, 0, PowerRequest.Operation.SPEND, AETHER, MagicAmount.ONE, + new PowerRequest.Consumer(ResourceLocation.parse("test:guard"))), NexusLedger.NO_HOOKS).status()); + assertThrows(IllegalArgumentException.class, () -> ledger.prepareDeposit(NODE, 10, 0, AETHER, 3)); + var commit = ledger.prepareDeposit(NODE, 10, 0, AETHER, 2); + assertEquals(1, ledger.quote(NODE, 10, 0).currency(AETHER)); + assertEquals(revision, ledger.revision()); + commit.run(); + return true; + })); + assertEquals(3, ledger.quote(NODE, 10, 0).currency(AETHER)); + assertEquals(MagicAmount.of(10), ledger.quote(NODE, 10, 0).total()); + assertEquals(revision + 1, ledger.revision()); + } +} diff --git a/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/ExplorationTelemetry.java b/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/ExplorationTelemetry.java new file mode 100644 index 0000000..ba97096 --- /dev/null +++ b/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/ExplorationTelemetry.java @@ -0,0 +1,94 @@ +package zone.moddev.mc.skysleylines.probe; + +import com.sun.management.OperatingSystemMXBean; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.management.ManagementFactory; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import jdk.jfr.Event; +import jdk.jfr.Label; +import jdk.jfr.Name; +import jdk.jfr.StackTrace; + +/** Process counters and JFR correlation for the eight end-to-end chunk requests. */ +final class ExplorationTelemetry { + private static final String HEADER = "index\tmode\tchunkX\tchunkZ\tstartEpochMs\twallNs\tprocessCpuNs" + + "\tserverCpuNs\tgcCollections\tgcMs\tjitMs"; + + @Name("skysleylines.ExplorationChunk") + @Label("Ley Lines exploration chunk") + @StackTrace(false) + private static final class ChunkEvent extends Event { + int index; + String mode; + int chunkX; + int chunkZ; + } + + private record Counters(long processCpuNs, long serverCpuNs, long gcCollections, long gcMs, long jitMs) {} + + private final String mode; + private final OperatingSystemMXBean os = ManagementFactory.getPlatformMXBean(OperatingSystemMXBean.class); + private final List collectors = ManagementFactory.getGarbageCollectorMXBeans(); + private final java.lang.management.ThreadMXBean threads = ManagementFactory.getThreadMXBean(); + private final java.lang.management.CompilationMXBean compiler = ManagementFactory.getCompilationMXBean(); + private final List rows = new ArrayList<>(9); + private ChunkEvent event; + private Counters before; + private long startEpochMs; + + ExplorationTelemetry(String mode) { + this.mode = mode; + rows.add(HEADER); + } + + void begin(int x, int z) { + before = counters(); + event = new ChunkEvent(); + event.index = rows.size(); + event.mode = mode; + event.chunkX = x; + event.chunkZ = z; + startEpochMs = System.currentTimeMillis(); + event.begin(); + } + + void end(long elapsedNs) { + event.end(); + Counters after = counters(); + rows.add(event.index + "\t" + mode + "\t" + event.chunkX + "\t" + event.chunkZ + "\t" + startEpochMs + + "\t" + elapsedNs + "\t" + delta(before.processCpuNs(), after.processCpuNs()) + + "\t" + delta(before.serverCpuNs(), after.serverCpuNs()) + + "\t" + delta(before.gcCollections(), after.gcCollections()) + "\t" + delta(before.gcMs(), after.gcMs()) + + "\t" + delta(before.jitMs(), after.jitMs())); + event.commit(); + } + + void write() { + try { + Files.write(Path.of("structure-telemetry.tsv"), rows); + } catch (IOException failure) { + throw new UncheckedIOException("Cannot write structure telemetry", failure); + } + } + + private Counters counters() { + long collections = 0; + long gcMs = 0; + for (var collector : collectors) { + collections += Math.max(0, collector.getCollectionCount()); + gcMs += Math.max(0, collector.getCollectionTime()); + } + long jitMs = compiler != null && compiler.isCompilationTimeMonitoringSupported() + ? compiler.getTotalCompilationTime() : -1; + return new Counters(os != null ? os.getProcessCpuTime() : -1, threads.getCurrentThreadCpuTime(), + collections, gcMs, jitMs); + } + + private static long delta(long before, long after) { + return before < 0 || after < 0 ? -1 : after - before; + } +} diff --git a/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/OptionalLeyWorldgenProbe.java b/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/OptionalLeyWorldgenProbe.java index 752e7ab..bd02a28 100644 --- a/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/OptionalLeyWorldgenProbe.java +++ b/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/OptionalLeyWorldgenProbe.java @@ -29,6 +29,7 @@ public OptionalLeyWorldgenProbe(IEventBus bus) { features.register("sample", SampleFeature::new); features.register(bus); present = ModList.get().isLoaded("skysleylines"); + StructureWorldgenProbe.register(bus, present); if (present) { NeoForge.EVENT_BUS.addListener(OptionalLeyWorldgenProbe::load); NeoForge.EVENT_BUS.addListener(OptionalLeyWorldgenProbe::unload); diff --git a/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/OptionalSampler.java b/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/OptionalSampler.java index b61a191..d825160 100644 --- a/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/OptionalSampler.java +++ b/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/OptionalSampler.java @@ -1,6 +1,9 @@ package zone.moddev.mc.skysleylines.probe; +import java.util.List; import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.levelgen.RandomState; import net.minecraft.server.level.ServerLevel; import zone.moddev.mc.skysleylines.api.LeyFieldSampler; import zone.moddev.mc.skysleylines.api.LeyLinesApi; @@ -13,6 +16,25 @@ static void load(ServerLevel level) { SAMPLERS.put(level, LeyLinesApi.forLevel(level).field()); } + static LeyFieldSampler sampler(ServerLevel level) { return SAMPLERS.get(level); } + + static Object lookup(RandomState state) { return LeyLinesApi.worldgenSampler(state).orElse(null); } + + static List sites(Object field, int x, int z) { return positions(query(field, x, z)); } + + static List query(Object field, int x, int z) { return ((LeyFieldSampler) field).nexusesInChunk(x, z); } + + static List positions(List sites) { + if (sites.isEmpty()) return List.of(); + return sites.stream().map(zone.moddev.mc.skysleylines.api.Nexus.class::cast) + .map(nexus -> BlockPos.containing(nexus.x(), nexus.authoredY().orElse(80), nexus.z())).toList(); + } + + static double column(Object field, int x, int z) { + var column = ((LeyFieldSampler) field).sampleColumn(x, z); + return column.naturalStrengthAt(column.naturalSurfaceY()); + } + static void unload(ServerLevel level) { SAMPLERS.remove(level); } static double sample(ServerLevel level, int x, int z) { diff --git a/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/StructureWorldgenProbe.java b/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/StructureWorldgenProbe.java new file mode 100644 index 0000000..7dbe709 --- /dev/null +++ b/src/worldgenProbe/java/zone/moddev/mc/skysleylines/probe/StructureWorldgenProbe.java @@ -0,0 +1,240 @@ +package zone.moddev.mc.skysleylines.probe; + +import com.mojang.logging.LogUtils; +import com.mojang.serialization.MapCodec; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ConcurrentSkipListSet; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Vec3i; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.structure.*; +import net.minecraft.world.level.levelgen.structure.pieces.*; +import net.minecraft.world.level.levelgen.structure.placement.*; +import net.minecraft.world.level.block.Blocks; +import net.neoforged.bus.api.EventPriority; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.level.LevelEvent; +import net.neoforged.neoforge.event.server.ServerStartedEvent; +import net.neoforged.neoforge.event.server.ServerStoppingEvent; +import net.neoforged.neoforge.event.server.ServerStoppedEvent; +import net.neoforged.neoforge.registries.DeferredRegister; + +/** Test-only real placement, starts, and pieces, with identical pieces in measured and control runs. */ +final class StructureWorldgenProbe { + private static final String MOD = OptionalLeyWorldgenProbe.MOD_ID; + private static final String MODE = System.getProperty("ley.structure.mode", "both"); + private static final boolean BENCH = Boolean.getBoolean("ley.structure.bench"); + private static final boolean END_TO_END_ONLY = Boolean.getBoolean("ley.structure.endToEndOnly"); + private static final boolean TELEMETRY = Boolean.getBoolean("ley.structure.telemetry"); + private static final boolean BASELINE = Boolean.getBoolean("ley.structure.baseline"); + private static final Path ORACLE = Path.of(System.getProperty("ley.structure.oracle", "sites.tsv")); + private static final ConcurrentSkipListSet STARTS = new ConcurrentSkipListSet<>(); + private static final ConcurrentLinkedQueue MEASUREMENTS = new ConcurrentLinkedQueue<>(); + private static final AtomicInteger PLACEMENTS = new AtomicInteger(); + private static final int[][] EXPLORATION = {{72, -47}, {74, -47}, {72, -45}, {74, -45}, + {76, -47}, {76, -45}, {72, -49}, {76, -49}}; + private static final com.sun.management.ThreadMXBean ALLOCATION = + (com.sun.management.ThreadMXBean) java.lang.management.ManagementFactory.getThreadMXBean(); + private static volatile Map> oracle = Map.of(); + private static volatile Object sampler; + private static volatile RandomState state; + private static volatile boolean exploration; + private static boolean present; + private static Supplier> structure; + private static Supplier> placement; + private static Supplier piece; + + private StructureWorldgenProbe() {} + + static void register(IEventBus bus, boolean available) { + present = available; + var structures = DeferredRegister.create(Registries.STRUCTURE_TYPE, MOD); + structure = structures.register("nexus", () -> (StructureType) () -> NexusStructure.CODEC); + structures.register(bus); + var placements = DeferredRegister.create(Registries.STRUCTURE_PLACEMENT, MOD); + placement = placements.register("nexus", () -> (StructurePlacementType) () -> NexusPlacement.CODEC); + placements.register(bus); + var pieces = DeferredRegister.create(Registries.STRUCTURE_PIECE, MOD); + piece = pieces.register("nexus", () -> (StructurePieceType) (context, tag) -> new Marker(tag)); + pieces.register(bus); + NeoForge.EVENT_BUS.addListener(EventPriority.LOWEST, StructureWorldgenProbe::load); + NeoForge.EVENT_BUS.addListener(StructureWorldgenProbe::started); + NeoForge.EVENT_BUS.addListener(StructureWorldgenProbe::stopping); + NeoForge.EVENT_BUS.addListener(EventPriority.LOWEST, StructureWorldgenProbe::stopped); + } + + private static void load(LevelEvent.Load event) { + if (!(event.getLevel() instanceof ServerLevel level) || !level.dimension().equals(Level.OVERWORLD)) return; + state = level.getChunkSource().randomState(); + if (present) sampler = OptionalSampler.sampler(level); + if (!present) return; + try { + if (MODE.equals("export")) { + var rows = new ArrayList(); + for (int x = -40; x <= 100; x++) for (int z = -80; z <= 40; z++) { + if (!inArea(x, z)) continue; + for (var site : OptionalSampler.sites(sampler, x, z)) + rows.add(site.getX() + "\t" + site.getY() + "\t" + site.getZ()); + } + Files.write(ORACLE, rows); + } + if (MODE.equals("control") || BENCH) { + var sites = new HashMap>(); + for (String row : Files.readAllLines(ORACLE)) { + String[] fields = row.split("\t"); + var pos = new BlockPos(Integer.parseInt(fields[0]), Integer.parseInt(fields[1]), Integer.parseInt(fields[2])); + sites.computeIfAbsent(ChunkPos.asLong(pos), ignored -> new ArrayList<>()).add(pos); + } + sites.replaceAll((ignored, list) -> List.copyOf(list)); + oracle = Map.copyOf(sites); + } + } catch (IOException failure) { + throw new IllegalStateException("Cannot prepare structure benchmark oracle", failure); + } + } + + private static boolean inArea(int x, int z) { + return (Math.abs(x) <= 40 && Math.abs(z) <= 40) || (x >= 50 && x <= 100 && z >= -80 && z <= -20); + } + + private static void started(ServerStartedEvent event) { + LogUtils.getLogger().info("LEY_STRUCTURE_SPAWN placements={} starts={}", PLACEMENTS.get(), STARTS.size()); + if (!BENCH) { exploration = true; return; } + exploration = true; + var level = event.getServer().overworld(); + var telemetry = TELEMETRY ? new ExplorationTelemetry(MODE) : null; + long begin = System.nanoTime(); + for (var chunk : EXPLORATION) { + if (telemetry != null) telemetry.begin(chunk[0], chunk[1]); + long start = System.nanoTime(); + level.getChunk(chunk[0], chunk[1]); + long elapsed = System.nanoTime() - start; + if (telemetry != null) telemetry.end(elapsed); + MEASUREMENTS.add("chunk\t" + elapsed + "\t0\t0"); + } + LogUtils.getLogger().info("LEY_STRUCTURE_BENCH_DONE mode={} placements={} starts={} elapsedNs={}", + MODE, PLACEMENTS.get(), STARTS.size(), System.nanoTime() - begin); + if (telemetry != null) telemetry.write(); + } + + private static void stopping(ServerStoppingEvent event) { + try { + Files.write(Path.of("structure-starts.tsv"), STARTS); + Files.write(Path.of("structure-measurements.tsv"), MEASUREMENTS); + } catch (IOException failure) { + throw new IllegalStateException("Cannot write structure benchmark receipts", failure); + } + } + + private static void stopped(ServerStoppedEvent event) { + if (present && !BASELINE && OptionalSampler.lookup(state) != null) + throw new IllegalStateException("Structure sampler outlived its server"); + LogUtils.getLogger().info("LEY_STRUCTURE_UNBOUND present={} baseline={}", present, BASELINE); + } + + private static long bytes(long thread) { return ALLOCATION.getThreadAllocatedBytes(thread); } + + private static void measured(String label, long nanos, long bytes, long blocked) { + MEASUREMENTS.add(label + "\t" + nanos + "\t" + bytes + "\t" + blocked); + } + + private static List sites(RandomState random, int x, int z, boolean measure) { + if (!inArea(x, z)) return List.of(); + if (!present) return x == 0 && z == 0 || x == 72 && z == -47 + ? List.of(new BlockPos(x * 16 + 8, 80, z * 16 + 8)) : List.of(); + if (random != state) return List.of(); + var expected = oracle.getOrDefault(ChunkPos.asLong(x, z), List.of()); + if (END_TO_END_ONLY && measure) { + PLACEMENTS.incrementAndGet(); + measure = false; + } + if (!measure) { + if (MODE.equals("control")) return expected; + var field = OptionalSampler.lookup(random); + if (field == null) throw new IllegalStateException("Missing structure sampler"); + return OptionalSampler.sites(field, x, z); + } + PLACEMENTS.incrementAndGet(); + long thread = Thread.currentThread().threadId(); + var mx = java.lang.management.ManagementFactory.getThreadMXBean(); + boolean sampleColumns = BENCH && Math.floorMod(x + z, 4) == 0; + long blocked = sampleColumns ? mx.getThreadInfo(thread).getBlockedCount() : 0; + long b0 = bytes(thread), t0 = System.nanoTime(); + var field = MODE.equals("control") ? sampler : OptionalSampler.lookup(random); + long t1 = System.nanoTime(), b1 = bytes(thread); + if (field == null) throw new IllegalStateException("Structure worker did not see its sampler"); + var queried = MODE.equals("control") ? expected : OptionalSampler.query(field, x, z); + long t2 = System.nanoTime(), b2 = bytes(thread); + long queryBlocked = sampleColumns ? mx.getThreadInfo(thread).getBlockedCount() - blocked : 0; + var actual = MODE.equals("control") ? expected : OptionalSampler.positions(queried); + if (BENCH && !expected.equals(actual)) throw new IllegalStateException("Structure sites differ at " + x + ", " + z); + measured("lookup", t1 - t0, b1 - b0, 0); + measured(actual.isEmpty() ? "chunk-empty" : "chunk-occupied", t2 - t1, b2 - b1, 0); + measured("combined", t2 - t0, b2 - b0, queryBlocked); + if (sampleColumns) { + blocked = mx.getThreadInfo(thread).getBlockedCount(); + long b3 = bytes(thread), t3 = System.nanoTime(); + double cold = OptionalSampler.column(field, x * 16 + 7, z * 16 + 7); + long t4 = System.nanoTime(), b4 = bytes(thread); + double warm = OptionalSampler.column(field, x * 16 + 7, z * 16 + 7); + long t5 = System.nanoTime(), b5 = bytes(thread); + if (cold != warm) throw new IllegalStateException("Column cache changed field result"); + measured("column-cold", t4 - t3, b4 - b3, mx.getThreadInfo(thread).getBlockedCount() - blocked); + measured("column-warm", t5 - t4, b5 - b4, 0); + } + return actual; + } + + private static final class NexusPlacement extends StructurePlacement { + static final MapCodec CODEC = MapCodec.unit(new NexusPlacement()); + NexusPlacement() { super(Vec3i.ZERO, FrequencyReductionMethod.DEFAULT, 1, 0, Optional.empty()); } + @Override protected boolean isPlacementChunk(ChunkGeneratorStructureState state, int x, int z) { + return !sites(state.randomState(), x, z, true).isEmpty(); + } + @Override public StructurePlacementType type() { return placement.get(); } + } + + private static final class NexusStructure extends Structure { + static final MapCodec CODEC = simpleCodec(NexusStructure::new); + NexusStructure(StructureSettings settings) { super(settings); } + @Override public StructureType type() { return structure.get(); } + @Override protected Optional findGenerationPoint(GenerationContext context) { + var chunk = context.chunkPos(); + var sites = sites(context.randomState(), chunk.x, chunk.z, false); + if (sites.isEmpty()) return Optional.empty(); + for (var site : sites) { + if (!chunk.equals(new ChunkPos(site))) throw new IllegalStateException("Structure site owned by wrong chunk"); + STARTS.add((exploration ? "exploration" : "spawn") + "\t" + site.toShortString()); + } + return Optional.of(new GenerationStub(sites.getFirst(), builder -> sites.forEach(site -> builder.addPiece(new Marker(site))))); + } + } + + private static final class Marker extends StructurePiece { + Marker(BlockPos pos) { super(piece.get(), 0, BoundingBox.fromCorners(pos, pos)); } + Marker(CompoundTag tag) { super(piece.get(), tag); } + @Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {} + @Override public void postProcess(WorldGenLevel level, StructureManager structures, ChunkGenerator generator, + RandomSource random, BoundingBox box, ChunkPos chunk, BlockPos origin) { + var pos = getBoundingBox().getCenter(); + if (box.isInside(pos)) level.setBlock(pos, Blocks.AMETHYST_BLOCK.defaultBlockState(), 2); + } + } +} diff --git a/src/worldgenProbe/resources/META-INF/neoforge.mods.toml b/src/worldgenProbe/resources/META-INF/neoforge.mods.toml index 965ea70..e97855a 100644 --- a/src/worldgenProbe/resources/META-INF/neoforge.mods.toml +++ b/src/worldgenProbe/resources/META-INF/neoforge.mods.toml @@ -8,6 +8,6 @@ displayName="Disposable optional Ley Lines worldgen probe" [[dependencies.leyworldgenprobe]] modId="skysleylines" type="optional" -versionRange="[0.5.4.121012]" +versionRange="[0.5.5.121012]" ordering="AFTER" side="BOTH" diff --git a/src/worldgenProbe/resources/data/leyworldgenprobe/worldgen/structure/nexus.json b/src/worldgenProbe/resources/data/leyworldgenprobe/worldgen/structure/nexus.json new file mode 100644 index 0000000..2652e46 --- /dev/null +++ b/src/worldgenProbe/resources/data/leyworldgenprobe/worldgen/structure/nexus.json @@ -0,0 +1,6 @@ +{ + "type": "leyworldgenprobe:nexus", + "biomes": "#minecraft:is_overworld", + "step": "surface_structures", + "spawn_overrides": {} +} diff --git a/src/worldgenProbe/resources/data/leyworldgenprobe/worldgen/structure_set/nexus.json b/src/worldgenProbe/resources/data/leyworldgenprobe/worldgen/structure_set/nexus.json new file mode 100644 index 0000000..4872a31 --- /dev/null +++ b/src/worldgenProbe/resources/data/leyworldgenprobe/worldgen/structure_set/nexus.json @@ -0,0 +1,4 @@ +{ + "structures": [{"structure": "leyworldgenprobe:nexus", "weight": 1}], + "placement": {"type": "leyworldgenprobe:nexus"} +}