mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-11 19:14:11 +00:00
Tuning
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { jsonFiles } from './studio-tools.mjs'
|
||||
import { packKey, seed, setup, openStudio, closeStudio, exists, writeJson } from './studio-support.mjs'
|
||||
|
||||
const structure = 'acceptance/bot-graph'
|
||||
|
||||
async function playerWorld(context, username) {
|
||||
const observation = await context.observe()
|
||||
const player = observation.players.find(entry => entry.username === username)
|
||||
return observation.worlds.find(entry => entry.id === player?.world)
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'iris-editor-ownership',
|
||||
description: 'Verify concurrent ordinary visitors, competing administrator rejection, and owner reconnect across two explicit phases.',
|
||||
async run(context) {
|
||||
const phase = context.options.command ?? 'exercise'
|
||||
context.expect(['exercise', 'reconnect'].includes(phase), 'Choose exercise or reconnect')
|
||||
const fixture = await setup(context)
|
||||
context.expect(fixture.state.studioCompleted, 'Complete Studio acceptance first')
|
||||
context.expect(await exists(path.join(fixture.pack, 'structures', `${structure}.json`)), 'Complete the Jigsaw authoring phase first')
|
||||
let active
|
||||
let jigsaw = false
|
||||
let editor
|
||||
let promoted = false
|
||||
let retainForReconnect = false
|
||||
try {
|
||||
if (phase === 'reconnect') {
|
||||
const owned = fixture.state.ownership
|
||||
context.expect(owned && owned.username === context.bot.username, 'Reconnect with the same primary owner after exercise')
|
||||
context.expect(owned.processId === (await context.observe()).processId, 'Reconnect requires the same running server')
|
||||
await fixture.wait(async () => (await fixture.currentWorld()).id === owned.id, 'Owner did not reconnect to the retained workspace')
|
||||
active = owned
|
||||
jigsaw = true
|
||||
await context.command('/iris jigsaw status', /mode=SPATIAL.*variants=7/, 10000)
|
||||
await context.command('/iris jigsaw goto workcell/spatial', /Selected Jigsaw Studio workcell/, 10000)
|
||||
await closeStudio(context, fixture, active, true)
|
||||
active = undefined
|
||||
delete fixture.state.ownership
|
||||
await writeJson(fixture.marker, fixture.state)
|
||||
context.report.iris.ownership = { reconnected: true, retainedWorld: owned.id, closed: true }
|
||||
return
|
||||
}
|
||||
context.expect(!fixture.state.ownership, 'Finish the pending reconnect phase before another exercise')
|
||||
const visitor = await context.connectActor(`IrisView${context.server.port}`)
|
||||
editor = await context.connectActor(`IrisEdit${context.server.port}`)
|
||||
await context.step('Walk two ordinary visitors concurrently in the same Studio world', async () => {
|
||||
active = await openStudio(context, fixture)
|
||||
for (const [actor, centerX] of [[visitor, -8.5], [editor, 8.5]]) {
|
||||
await context.command(`/minecraft:tp ${actor.bot.username} ${context.bot.username}`)
|
||||
await context.command(`/minecraft:gamemode survival ${actor.bot.username}`)
|
||||
await context.command(`/minecraft:tp ${actor.bot.username} ${centerX + 4} 97 0.5`)
|
||||
}
|
||||
await fixture.wait(async () => (await playerWorld(context, visitor.bot.username))?.id === active.id
|
||||
&& (await playerWorld(context, editor.bot.username))?.id === active.id, 'Both visitors did not enter the same Studio world')
|
||||
const circuits = await Promise.all([
|
||||
visitor.actions.walkCircle({ center: { x: -8.5, y: 97, z: 0.5 }, radius: 4, laps: 1 }),
|
||||
editor.actions.walkCircle({ center: { x: 8.5, y: 97, z: 0.5 }, radius: 4, laps: 1 })
|
||||
])
|
||||
context.report.iris.visitors = { usernames: [visitor.bot.username, editor.bot.username], world: active.id, circuits }
|
||||
await closeStudio(context, fixture, active)
|
||||
const closedId = active.id
|
||||
active = undefined
|
||||
await fixture.wait(async () => (await playerWorld(context, visitor.bot.username))?.id !== closedId
|
||||
&& (await playerWorld(context, editor.bot.username))?.id !== closedId, 'Studio close did not evacuate every visitor')
|
||||
})
|
||||
await context.step('Reject a competing administrator while retaining the owner workspace', async () => {
|
||||
await context.command(`/minecraft:op ${editor.bot.username}`, /operator/i, 10000)
|
||||
promoted = true
|
||||
context.report.iris.editorRole = 'Temporary administrator, explicitly promoted after ordinary-visitor coverage and deopped in cleanup'
|
||||
active = await openStudio(context, fixture, `/iris jigsaw open ${packKey} ${structure} seed=${seed}`)
|
||||
jigsaw = true
|
||||
const resourceFiles = await jsonFiles(path.join(fixture.pack, 'jigsaw-pieces'))
|
||||
const variants = await Promise.all(resourceFiles.map(async file => [file, await readFile(file, 'utf8')]))
|
||||
const structureFile = path.join(fixture.pack, 'structures', `${structure}.json`)
|
||||
const original = await readFile(structureFile, 'utf8')
|
||||
await editor.command(`/iris jigsaw open ${packKey} ${structure} seed=${seed}`, /Jigsaw Studio is already owned by another player session\./, 10000)
|
||||
context.expect((await fixture.currentWorld()).id === active.id, 'Competing open displaced the owner')
|
||||
await context.command(`/minecraft:tp ${editor.bot.username} ${context.bot.username}`)
|
||||
await fixture.wait(async () => (await playerWorld(context, editor.bot.username))?.id === active.id, 'Competing editor did not arrive')
|
||||
await editor.command('/iris jigsaw variant label UnauthorizedChange', /owned by another player session/i, 10000)
|
||||
await editor.command('/iris jigsaw close', /owned by another player session/i, 10000)
|
||||
context.expect(await readFile(structureFile, 'utf8') === original, 'Rejected editor changed the structure')
|
||||
for (const [file, bytes] of variants) context.expect(await readFile(file, 'utf8') === bytes, 'Rejected editor changed a variant')
|
||||
await context.command('/iris jigsaw status', /mode=SPATIAL.*variants=7/, 10000)
|
||||
context.expect((await fixture.currentWorld()).id === active.id, 'Rejected close displaced the owner')
|
||||
await context.command(`/minecraft:deop ${editor.bot.username}`, /operator/i, 10000)
|
||||
promoted = false
|
||||
fixture.state.ownership = { id: active.id, key: active.key, name: active.name, username: context.bot.username,
|
||||
processId: (await context.observe()).processId }
|
||||
await writeJson(fixture.marker, fixture.state)
|
||||
context.report.iris.ownership = { competingOpenRejected: true, mutationRejected: true, closeRejected: true, retainedWorld: active.id }
|
||||
context.report.iris.next = 'Run this scenario with --command reconnect against the same running server and primary username.'
|
||||
retainForReconnect = true
|
||||
})
|
||||
} finally {
|
||||
if (promoted) await context.command(`/minecraft:deop ${editor.bot.username}`, /operator/i, 10000)
|
||||
if (active && !retainForReconnect) await closeStudio(context, fixture, active, jigsaw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { readFile, writeFile, cp } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { packKey, worldKey, seed, parsePhase, setup, exists, writeJson, readJson,
|
||||
openStudio, closeStudio, sampleTerrain, logPosition, logSince } from './studio-support.mjs'
|
||||
|
||||
async function prepare(context, fixture) {
|
||||
context.expect(!(await exists(fixture.marker)) && !(await exists(fixture.pack)), 'Prepare requires a new disposable instance and an unused fixture pack')
|
||||
const settingsFile = path.join(fixture.irisRoot, 'iris.json')
|
||||
const settings = await readJson(settingsFile)
|
||||
settings.studio = { ...settings.studio, openVSCode: false, entitySpawning: false }
|
||||
await writeJson(settingsFile, settings)
|
||||
await context.command(`/iris studio create name=${packKey}`, new RegExp(`Created studio project '${packKey}'`), 180000)
|
||||
for (const relative of [`dimensions/${packKey}.json`, 'regions/starter.json', 'biomes/starter.json', `${packKey}.code-workspace`]) {
|
||||
context.expect(await exists(path.join(fixture.pack, relative)), `Studio create omitted ${relative}`)
|
||||
}
|
||||
const source = fileURLToPath(new URL('./native-terrain-pack/', import.meta.url))
|
||||
for (const directory of ['regions', 'biomes', 'generators']) {
|
||||
await cp(path.join(source, directory), path.join(fixture.pack, directory), { recursive: true })
|
||||
}
|
||||
const dimension = await readJson(path.join(source, 'dimensions', 'native-terrain.json'))
|
||||
dimension.name = 'Bot Studio Acceptance'
|
||||
await writeJson(path.join(fixture.pack, 'dimensions', `${packKey}.json`), dimension)
|
||||
const state = { schemaVersion: 1, instance: fixture.root, pack: packKey, preparedProcess: (await context.observe()).processId,
|
||||
material: 'lime_wool', productionCreated: false, studioCompleted: false }
|
||||
await writeJson(fixture.marker, state)
|
||||
context.report.iris.next = 'Restart the isolated server before the studio phase so its pack registry is loaded.'
|
||||
}
|
||||
|
||||
async function editBiome(context, fixture, world, material, title) {
|
||||
const file = path.join(fixture.pack, 'biomes', 'flat.json')
|
||||
const manifestFile = fixture.manifestPath(world.key)
|
||||
const before = await readJson(manifestFile)
|
||||
const biome = await readJson(file)
|
||||
biome.name = title
|
||||
biome.layers[0].palette[0].block = `minecraft:${material}`
|
||||
await writeJson(file, biome)
|
||||
const after = await fixture.wait(async () => {
|
||||
const manifest = await readJson(manifestFile)
|
||||
return manifest.activeActivationId !== before.activeActivationId ? manifest : false
|
||||
}, 'The saved pack edit did not activate through the Studio watcher', 90000)
|
||||
context.report.iris.activations.push({ world: world.id, before: before.activeActivationId, after: after.activeActivationId, material })
|
||||
}
|
||||
|
||||
async function studio(context, fixture, cycles) {
|
||||
context.expect((await context.observe()).processId !== fixture.state.preparedProcess, 'Restart the server after prepare before opening the new pack')
|
||||
let active
|
||||
const biomeFile = path.join(fixture.pack, 'biomes', 'flat.json')
|
||||
let retainedBytes
|
||||
try {
|
||||
for (let cycle = 0; cycle < cycles; cycle++) {
|
||||
await context.step(`Open Studio and inspect the accepted pack, cycle ${cycle + 1}`, async () => {
|
||||
active = await openStudio(context, fixture)
|
||||
await fixture.wait(() => context.bot.game.gameMode === 'spectator', 'Ordinary Studio did not enter spectator mode')
|
||||
await sampleTerrain(context, fixture, 0, fixture.state.material)
|
||||
})
|
||||
await context.step(`Apply a real pack edit and inspect new terrain, cycle ${cycle + 1}`, async () => {
|
||||
const material = fixture.state.material === 'lime_wool' ? 'magenta_wool' : 'lime_wool'
|
||||
await editBiome(context, fixture, active, material, `Acceptance Cycle ${cycle + 1}`)
|
||||
await sampleTerrain(context, fixture, 4096, material)
|
||||
await sampleTerrain(context, fixture, 0, fixture.state.material)
|
||||
fixture.state.material = material
|
||||
await writeJson(fixture.marker, fixture.state)
|
||||
})
|
||||
await context.step(`Reject a malformed edit while retaining live generation, cycle ${cycle + 1}`, async () => {
|
||||
retainedBytes = await readFile(biomeFile, 'utf8')
|
||||
const before = await readJson(fixture.manifestPath(active.key))
|
||||
const logOffset = await logPosition(fixture)
|
||||
await writeFile(biomeFile, '{ "name": "Rejected fixture", "layers": [')
|
||||
const rejection = await fixture.wait(async () => {
|
||||
const log = await logSince(fixture, logOffset)
|
||||
return /Studio generation update failed/.test(log) ? log : false
|
||||
}, 'The malformed edit did not produce a Studio rejection', 90000)
|
||||
context.report.iris.rejections ??= []
|
||||
context.report.iris.rejections.push({ world: active.id, evidence: rejection.split('\n').filter(line => /Studio generation update failed/.test(line)) })
|
||||
context.expect((await readJson(fixture.manifestPath(active.key))).activeActivationId === before.activeActivationId,
|
||||
'A rejected edit changed the active generation')
|
||||
await sampleTerrain(context, fixture, 8192, fixture.state.material)
|
||||
await writeFile(biomeFile, retainedBytes)
|
||||
retainedBytes = undefined
|
||||
await editBiome(context, fixture, active, fixture.state.material, `Recovered Cycle ${cycle + 1}`)
|
||||
await sampleTerrain(context, fixture, 12288, fixture.state.material)
|
||||
})
|
||||
await context.step(`Close and remove the transient world, cycle ${cycle + 1}`, async () => {
|
||||
await closeStudio(context, fixture, active)
|
||||
active = undefined
|
||||
})
|
||||
}
|
||||
await context.step('Reopen the last accepted pack and verify persistence', async () => {
|
||||
active = await openStudio(context, fixture)
|
||||
await sampleTerrain(context, fixture, 0, fixture.state.material)
|
||||
await closeStudio(context, fixture, active)
|
||||
active = undefined
|
||||
})
|
||||
fixture.state.studioCompleted = true
|
||||
await writeJson(fixture.marker, fixture.state)
|
||||
} finally {
|
||||
if (retainedBytes !== undefined) await writeFile(biomeFile, retainedBytes)
|
||||
if (active) await closeStudio(context, fixture, active)
|
||||
}
|
||||
}
|
||||
|
||||
async function production(context, fixture, create) {
|
||||
context.expect(fixture.state.studioCompleted, 'Complete the Studio acceptance phase before creating a production world')
|
||||
const key = `iris:${worldKey}`
|
||||
if (create) {
|
||||
context.expect(!(await exists(fixture.worldRoot(key))), 'The production fixture world already exists')
|
||||
await context.command(`/iris create name=${worldKey} type=${packKey} seed=${seed}`, /Successfully created your world/i, 180000)
|
||||
fixture.state.productionCreated = true
|
||||
fixture.state.productionProcess = (await context.observe()).processId
|
||||
fixture.state.productionMaterial = fixture.state.material
|
||||
await writeJson(fixture.marker, fixture.state)
|
||||
}
|
||||
context.expect(fixture.state.productionCreated, 'Create the production fixture before world-check')
|
||||
if (!create) context.expect((await context.observe()).processId !== fixture.state.productionProcess, 'Run world-check after a real server restart')
|
||||
const previous = await fixture.currentWorld()
|
||||
const world = await fixture.arrival(() => context.command(`/iris tp ${worldKey}`),
|
||||
candidate => candidate.name === worldKey || candidate.name === `iris_${worldKey}` || candidate.name.endsWith(`_iris_${worldKey}`), 'The bot did not enter the created Iris world')
|
||||
try {
|
||||
await context.command('/minecraft:gamemode spectator @s')
|
||||
await fixture.wait(() => context.bot.game.gameMode === 'spectator', 'Spectator mode did not reach the bot')
|
||||
await context.command('/minecraft:seed', new RegExp(`Seed:.*${seed}`))
|
||||
await sampleTerrain(context, fixture, create ? 0 : 4096, fixture.state.productionMaterial)
|
||||
{
|
||||
const file = path.join(fixture.pack, 'biomes', 'flat.json')
|
||||
const original = await readFile(file, 'utf8')
|
||||
try {
|
||||
const edited = JSON.parse(original)
|
||||
edited.layers[0].palette[0].block = 'minecraft:gold_block'
|
||||
await writeJson(file, edited)
|
||||
await sampleTerrain(context, fixture, 8192, fixture.state.productionMaterial)
|
||||
} finally { await writeFile(file, original) }
|
||||
}
|
||||
const manifest = await readJson(fixture.manifestPath(key))
|
||||
context.expect(manifest.epochs.length > 0 && manifest.pendingActivationId === null, 'Production world has no active immutable generation epoch')
|
||||
context.report.iris.productionManifest = manifest
|
||||
} finally {
|
||||
await fixture.arrival(() => context.command(`/minecraft:execute in minecraft:overworld run tp ${context.bot.username} 0 110 0`),
|
||||
candidate => candidate.id !== world.id, 'The bot did not leave the production world')
|
||||
}
|
||||
context.expect(previous.id !== world.id || !create, 'Creation did not establish a separate world')
|
||||
}
|
||||
|
||||
async function replacement(context, fixture, check) {
|
||||
context.expect(fixture.state.productionCreated, 'Complete production-world creation before replacing the disposable vanilla world')
|
||||
const key = 'minecraft:overworld'
|
||||
if (!check) {
|
||||
context.expect(!fixture.state.replacement && !(await exists(fixture.manifestPath(key))), 'Replacement requires the original vanilla fixture world')
|
||||
context.expect(await exists(path.join(fixture.worldRoot(key), 'data', 'minecraft', 'world_gen_settings.dat')), 'The exact vanilla dimension has no saved generation settings')
|
||||
const overworld = (await context.observe()).worlds.find(world => world.name === fixture.level)
|
||||
context.expect(overworld, 'Observer did not identify the vanilla Overworld')
|
||||
fixture.state.replacement = { processId: (await context.observe()).processId, previousId: overworld.id, staged: false }
|
||||
await writeJson(fixture.marker, fixture.state)
|
||||
await context.command(`/iris replace ${key} type=${packKey} seed=${seed}`, /Staged Iris replacement for minecraft:overworld.*Restart once to publish/i, 180000)
|
||||
fixture.state.replacement.staged = true
|
||||
fixture.state.replacement.material = fixture.state.material
|
||||
await writeJson(fixture.marker, fixture.state)
|
||||
context.report.iris.next = 'Restart the isolated server, then run replace-check.'
|
||||
return
|
||||
}
|
||||
context.expect(fixture.state.replacement?.staged, 'No successful replacement stage was recorded')
|
||||
context.expect((await context.observe()).processId !== fixture.state.replacement.processId, 'Replacement must be checked after a real server restart')
|
||||
await fixture.arrival(() => context.command(`/minecraft:execute in ${key} run tp ${context.bot.username} 0 110 0`),
|
||||
candidate => candidate.name === fixture.level, 'The bot did not enter the replaced canonical Overworld')
|
||||
await context.command('/minecraft:gamemode spectator @s')
|
||||
await fixture.wait(() => context.bot.game.gameMode === 'spectator', 'Spectator mode did not reach the bot')
|
||||
await context.command('/minecraft:seed', new RegExp(`Seed:.*${seed}`))
|
||||
await sampleTerrain(context, fixture, 0, fixture.state.replacement.material)
|
||||
await sampleTerrain(context, fixture, 4096, fixture.state.replacement.material)
|
||||
const manifest = await readJson(fixture.manifestPath(key))
|
||||
context.expect(manifest.epochs.length > 0 && manifest.pendingActivationId === null, 'Replacement did not publish an immutable Iris generation')
|
||||
context.report.iris.replacementManifest = manifest
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'iris-studio-acceptance',
|
||||
description: 'Exercise public Studio creation, repeated pack edits, rejection retention, lifecycle, production creation, and cold vanilla replacement.',
|
||||
async run(context) {
|
||||
const { phase, cycles } = parsePhase(context.options.command)
|
||||
const fixture = await setup(context, { prepare: phase === 'prepare' })
|
||||
if (phase === 'prepare') return context.step('Create and prepare a disposable Studio pack through its public command', () => prepare(context, fixture))
|
||||
if (phase === 'studio') return studio(context, fixture, cycles)
|
||||
if (phase === 'world' || phase === 'world-check') return context.step('Verify a production world through public commands and terrain', () => production(context, fixture, phase === 'world'))
|
||||
if (phase === 'replace-stage' || phase === 'replace-check') return context.step(phase, () => replacement(context, fixture, phase === 'replace-check'))
|
||||
const tools = await import('./studio-tools.mjs')
|
||||
return tools.runTools(context, fixture, phase)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { readFile, writeFile, realpath, stat, open } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
export const packKey = 'qa-bot-studio'
|
||||
export const worldKey = 'qa-bot-world'
|
||||
export const seed = 78264193
|
||||
|
||||
export function parsePhase(value = 'studio:3') {
|
||||
const match = /^(prepare|studio|object|jigsaw|world|world-check|replace-stage|replace-check)(?::([1-9]\d?))?$/.exec(value)
|
||||
if (!match || (match[2] && match[1] !== 'studio')) throw new Error(`Invalid Iris acceptance phase: ${value}`)
|
||||
const cycles = Number(match[2] ?? 3)
|
||||
if (cycles > 20) throw new Error('Studio cycles must be between 1 and 20')
|
||||
return { phase: match[1], cycles }
|
||||
}
|
||||
|
||||
export async function exists(file) {
|
||||
try { await stat(file); return true } catch (error) {
|
||||
if (error.code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJson(file, value) {
|
||||
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
export async function readJson(file) {
|
||||
return JSON.parse(await readFile(file, 'utf8'))
|
||||
}
|
||||
|
||||
export function ownedPath(root, ...segments) {
|
||||
const target = path.resolve(root, ...segments)
|
||||
if (!target.startsWith(`${path.resolve(root)}${path.sep}`)) throw new Error('Fixture path escapes the isolated instance')
|
||||
return target
|
||||
}
|
||||
|
||||
export async function setup(context, { prepare = false } = {}) {
|
||||
const directory = context.server.directory
|
||||
context.expect(typeof directory === 'string' && path.isAbsolute(directory), 'The harness must supply an absolute instance directory')
|
||||
const root = await realpath(directory)
|
||||
const source = await readFile(path.join(root, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'Iris acceptance requires an isolated instance')
|
||||
const properties = await readFile(path.join(root, 'server.properties'), 'utf8')
|
||||
context.expect(/^server-ip=127\.0\.0\.1\s*$/m.test(properties), 'Iris acceptance requires a loopback instance')
|
||||
const level = /^level-name=(.+)$/m.exec(properties)?.[1].trim() ?? 'world'
|
||||
const levelRoot = ownedPath(root, level)
|
||||
const irisRoot = await realpath(path.join(root, 'plugins', 'Iris'))
|
||||
context.expect(irisRoot.startsWith(`${root}${path.sep}`), 'Iris data must belong to this instance')
|
||||
const packs = await realpath(path.join(irisRoot, 'packs'))
|
||||
context.expect(packs.startsWith(`${root}${path.sep}`), 'Shared Iris packs are not a test target')
|
||||
const marker = path.join(root, '.multiplexor-iris-acceptance.json')
|
||||
const state = prepare ? null : await readJson(marker)
|
||||
if (state) {
|
||||
context.expect(state.schemaVersion === 1 && state.instance === root && state.pack === packKey,
|
||||
'The prepared fixture does not belong to this instance')
|
||||
}
|
||||
const pack = path.join(packs, packKey)
|
||||
if (state) context.expect((await realpath(pack)).startsWith(`${root}${path.sep}`), 'The owned fixture pack was redirected outside the instance')
|
||||
const worldRoot = key => ownedPath(levelRoot, 'dimensions', ...key.split(':'))
|
||||
const manifestPath = key => path.join(worldRoot(key), 'iris', 'generation', 'manifest.json')
|
||||
await context.waitUntil(async () => (await context.observe()).players.some(entry => entry.username === context.bot.username),
|
||||
{ timeoutMs: 15000, label: 'observer registration for the scenario player' })
|
||||
const currentWorld = async () => {
|
||||
const observation = await context.observe()
|
||||
const player = observation.players.find(entry => entry.username === context.bot.username)
|
||||
context.expect(player, 'Observer does not contain the scenario player')
|
||||
const world = observation.worlds.find(entry => entry.id === player.world)
|
||||
context.expect(world, 'Observer does not contain the player world')
|
||||
return world
|
||||
}
|
||||
const wait = (predicate, label, timeoutMs = 30000) => context.waitUntil(predicate, { label, timeoutMs, intervalMs: 100 })
|
||||
const arrival = async (trigger, predicate, label) => {
|
||||
const previous = await context.observe()
|
||||
const startedAt = Date.now()
|
||||
let refusal
|
||||
const messages = message => {
|
||||
if (/Cannot convert |Unknown parameter|Missing required|Jigsaw project was not created|Studio open failed|restart before opening|Could not resolve the requested Iris pack/.test(message)) refusal = message
|
||||
}
|
||||
context.bot.on('messagestr', messages)
|
||||
try {
|
||||
await trigger()
|
||||
const world = await wait(async () => {
|
||||
context.expect(!refusal, `Public Iris command refused: ${refusal}`)
|
||||
const observation = await context.observe()
|
||||
context.expect(observation.processId === previous.processId, 'Server restarted during Studio arrival')
|
||||
if (Date.parse(observation.observedAt) <= startedAt) return false
|
||||
const current = await currentWorld()
|
||||
return predicate(current) && current.loadedChunks > 0 ? current : false
|
||||
}, label, 180000)
|
||||
await wait(() => context.bot.blockAt(context.bot.entity.position) !== null, 'Destination chunk did not reach the bot')
|
||||
context.report.iris.worldVisits.push({ id: world.id, name: world.name, observedAt: (await context.observe()).observedAt })
|
||||
return world
|
||||
} finally { context.bot.removeListener('messagestr', messages) }
|
||||
}
|
||||
context.report.iris = { phase: context.options.command ?? 'studio:3', pack: packKey, worldVisits: [], columns: [], activations: [] }
|
||||
return { root, level, levelRoot, irisRoot, pack, marker, state, worldRoot, manifestPath, currentWorld, wait, arrival }
|
||||
}
|
||||
|
||||
export async function openStudio(context, fixture, command = `/iris studio open ${packKey} seed=${seed}`) {
|
||||
const before = new Set((await context.observe()).worlds.map(world => world.id))
|
||||
const world = await fixture.arrival(() => context.command(command),
|
||||
candidate => !before.has(candidate.id) && /iris-[0-9a-f-]{36}$/.test(candidate.name), 'The public Studio command did not create and enter a new transient world')
|
||||
const name = /iris-[0-9a-f-]{36}$/.exec(world.name)[0]
|
||||
return { ...world, key: `iris:${name}` }
|
||||
}
|
||||
|
||||
export async function closeStudio(context, fixture, world, jigsaw = false) {
|
||||
await context.command(jigsaw ? '/iris jigsaw close' : '/iris studio close',
|
||||
jigsaw ? /^Jigsaw Studio closed\.$/ : /^Studio closed\.$/, 180000)
|
||||
await fixture.wait(async () => !(await context.observe()).worlds.some(entry => entry.id === world.id), 'Studio world remained loaded after close', 180000)
|
||||
await fixture.wait(async () => !(await exists(fixture.worldRoot(world.key))), 'Studio world directory survived cleanup', 180000)
|
||||
context.expect((await fixture.currentWorld()).id !== world.id, 'Studio close did not evacuate the bot')
|
||||
}
|
||||
|
||||
export async function sampleTerrain(context, fixture, x, material, expectedHeight = 96) {
|
||||
await context.command(`/minecraft:tp @s ${x + 0.5} ${expectedHeight + 5} 0.5`, /Teleported/i, 30000)
|
||||
await fixture.wait(() => Math.abs(context.bot.entity.position.x - x - 0.5) < 1, 'Terrain sample teleport did not arrive')
|
||||
const samples = []
|
||||
for (const offset of [0, 1, 2, 3]) {
|
||||
const target = context.bot.entity.position.clone().set(x + offset, expectedHeight, 0)
|
||||
const block = await fixture.wait(() => context.bot.blockAt(target), 'Terrain column did not reach the bot', 60000)
|
||||
const above = await fixture.wait(() => context.bot.blockAt(target.offset(0, 1, 0)), 'Terrain headroom did not reach the bot')
|
||||
context.expect(block.name === material && above.name === 'air', 'Generated column differs from the fixture',
|
||||
{ x: x + offset, y: expectedHeight, expected: material, actual: block.name, above: above.name })
|
||||
samples.push({ x: x + offset, y: expectedHeight, block: block.name, above: above.name })
|
||||
}
|
||||
context.report.iris.columns.push({ world: (await fixture.currentWorld()).id, samples })
|
||||
}
|
||||
|
||||
export async function logPosition(fixture) {
|
||||
return (await stat(path.join(fixture.root, 'logs', 'latest.log'))).size
|
||||
}
|
||||
|
||||
export async function logSince(fixture, offset) {
|
||||
const handle = await open(path.join(fixture.root, 'logs', 'latest.log'), 'r')
|
||||
try {
|
||||
const size = (await handle.stat()).size
|
||||
const start = Math.max(offset, size - 1024 * 1024)
|
||||
const buffer = Buffer.alloc(Math.max(0, size - start))
|
||||
await handle.read(buffer, 0, buffer.length, start)
|
||||
return buffer.toString('utf8')
|
||||
} finally { await handle.close() }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { parsePhase, ownedPath, setup, packKey } from './studio-support.mjs'
|
||||
|
||||
test('bulk bounds and destructive phases require explicit valid selection', () => {
|
||||
assert.deepEqual(parsePhase(), { phase: 'studio', cycles: 3 })
|
||||
assert.deepEqual(parsePhase('studio:20'), { phase: 'studio', cycles: 20 })
|
||||
for (const value of ['studio:0', 'studio:21', 'studio:999999', 'replace-stage:2', 'replace', 'studio;stop', '']) {
|
||||
assert.throws(() => parsePhase(value))
|
||||
}
|
||||
})
|
||||
|
||||
test('fixture paths reject absolute and parent escapes', () => {
|
||||
assert.equal(ownedPath('/test/instance', 'world', 'dimensions'), '/test/instance/world/dimensions')
|
||||
for (const value of ['..', '../instance-other', '/test/another', '.']) {
|
||||
assert.throws(() => ownedPath('/test/instance', value))
|
||||
}
|
||||
})
|
||||
|
||||
async function fixture(run) {
|
||||
const base = await mkdtemp(path.join(os.tmpdir(), 'iris-gameplay-'))
|
||||
const root = path.join(base, 'instance')
|
||||
const packs = path.join(root, 'plugins', 'Iris', 'packs')
|
||||
await mkdir(packs, { recursive: true })
|
||||
await writeFile(path.join(root, '.server-source'), 'type=paper\nisolated=true\n')
|
||||
await writeFile(path.join(root, 'server.properties'), 'server-ip=127.0.0.1\nlevel-name=world\n')
|
||||
const context = {
|
||||
server: { directory: root }, options: {}, report: {}, bot: { username: 'FixtureBot' },
|
||||
expect: (condition, message) => assert.ok(condition, message)
|
||||
}
|
||||
try { await run({ base, root, packs, context }) } finally { await rm(base, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
test('shared packs are refused even if the instance claims isolation', async () => fixture(async ({ base, packs, context }) => {
|
||||
const shared = path.join(base, 'shared-packs')
|
||||
await mkdir(shared)
|
||||
await rm(packs, { recursive: true })
|
||||
await symlink(shared, packs)
|
||||
await assert.rejects(setup(context, { prepare: true }), /Shared Iris packs/)
|
||||
}))
|
||||
|
||||
test('normal and non-loopback instances cannot enter acceptance', async () => fixture(async ({ root, context }) => {
|
||||
await writeFile(path.join(root, '.server-source'), 'type=paper\n')
|
||||
await assert.rejects(setup(context, { prepare: true }), /isolated instance/)
|
||||
await writeFile(path.join(root, '.server-source'), 'isolated=true\n')
|
||||
await writeFile(path.join(root, 'server.properties'), 'server-ip=0.0.0.0\n')
|
||||
await assert.rejects(setup(context, { prepare: true }), /loopback instance/)
|
||||
}))
|
||||
|
||||
test('a copied ownership marker cannot authorize another instance', async () => fixture(async ({ root, context }) => {
|
||||
await writeFile(path.join(root, '.multiplexor-iris-acceptance.json'), JSON.stringify({
|
||||
schemaVersion: 1, pack: packKey, instance: '/another/server'
|
||||
}))
|
||||
await assert.rejects(setup(context), /does not belong/)
|
||||
}))
|
||||
@@ -0,0 +1,119 @@
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { packKey, seed, exists, openStudio, closeStudio } from './studio-support.mjs'
|
||||
|
||||
async function objectTools(context, fixture) {
|
||||
const objectKey = 'acceptance-marker'
|
||||
const objectFile = path.join(fixture.pack, 'objects', `${objectKey}.iob`)
|
||||
context.expect(!(await exists(objectFile)), 'Object acceptance requires an unused marker object')
|
||||
let active
|
||||
try {
|
||||
active = await openStudio(context, fixture)
|
||||
await context.step('Select and save one real block through the Iris wand commands', async () => {
|
||||
await context.command('/minecraft:gamemode creative @s', /creative/i)
|
||||
await fixture.wait(() => context.bot.game.gameMode === 'creative', 'Creative mode did not reach the bot')
|
||||
context.bot.creative.startFlying()
|
||||
await context.command('/minecraft:tp @s 0.5 111 0.5', /Teleported/i)
|
||||
await fixture.wait(() => Math.abs(context.bot.entity.position.y - 111) < 0.1, 'Object selection position did not arrive')
|
||||
context.bot.creative.startFlying()
|
||||
await context.command('/minecraft:setblock 0 110 0 minecraft:diamond_block', /changed/i)
|
||||
await fixture.wait(() => context.bot.blockAt(context.bot.entity.position.clone().set(0, 110, 0))?.name === 'diamond_block', 'Selection block did not reach the bot')
|
||||
const existing = new Set(context.bot.inventory.items().map(item => item.slot))
|
||||
await context.command('/iris object wand')
|
||||
const wand = await fixture.wait(() => context.bot.inventory.items().find(item => !existing.has(item.slot)), 'The Iris wand was not received')
|
||||
await context.bot.equip(wand, 'hand')
|
||||
for (const corner of ['position1', 'position2']) {
|
||||
const before = JSON.stringify(context.bot.heldItem)
|
||||
await context.command(`/iris object ${corner}`)
|
||||
await fixture.wait(() => JSON.stringify(context.bot.heldItem) !== before, `${corner} did not update the wand selection`)
|
||||
}
|
||||
await context.command(`/iris object save ${objectKey} dimension=${packKey}`, /Successfully object to saved:/, 60000)
|
||||
context.expect((await readFile(objectFile)).length > 0, 'Object save produced an empty IOB')
|
||||
await context.command(`/iris object analyze ${objectKey}`, /Object Size: 1 \* 1 \* 1/, 10000)
|
||||
})
|
||||
await context.step('Paste the saved object and undo the actual block change', async () => {
|
||||
await context.command('/minecraft:tp @s 16.5 100 0.5', /Teleported/i)
|
||||
await fixture.wait(() => Math.abs(context.bot.entity.position.x - 16.5) < 0.1, 'Paste position did not arrive')
|
||||
context.bot.creative.startFlying()
|
||||
const target = context.bot.entity.position.clone().set(16.5, 96.5, 0.5)
|
||||
await fixture.wait(() => context.bot.blockAt(target), 'Paste target chunk did not arrive')
|
||||
await context.bot.lookAt(target, true)
|
||||
const placed = target.clone().set(16, 97, 0)
|
||||
context.expect(context.bot.blockAt(placed)?.name === 'air', 'Paste target was not empty')
|
||||
await context.command(`/iris object paste ${objectKey} scale=1`, /Placed/i, 30000)
|
||||
await fixture.wait(() => context.bot.blockAt(placed)?.name === 'diamond_block', 'Saved object did not become a real block')
|
||||
await context.command('/iris object undo', /Reverted 1.*pastes/i, 30000)
|
||||
await fixture.wait(() => context.bot.blockAt(placed)?.name === 'air', 'Undo did not restore the original air block')
|
||||
context.report.iris.object = { key: objectKey, saved: true, pasted: true, undone: true }
|
||||
})
|
||||
} finally {
|
||||
if (active) await closeStudio(context, fixture, active)
|
||||
}
|
||||
}
|
||||
|
||||
export async function jsonFiles(directory) {
|
||||
if (!(await exists(directory))) return []
|
||||
const files = []
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) files.push(...await jsonFiles(file))
|
||||
else if (entry.isFile() && entry.name.endsWith('.json')) files.push(file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function jigsawTools(context, fixture) {
|
||||
const structure = 'acceptance/bot-graph'
|
||||
context.expect(!(await exists(path.join(fixture.pack, 'structures', `${structure}.json`))), 'Jigsaw acceptance requires an unused structure key')
|
||||
let active
|
||||
try {
|
||||
await context.step('Create and enter a Jigsaw project through the public command', async () => {
|
||||
active = await openStudio(context, fixture,
|
||||
`/iris jigsaw create ${packKey} ${structure} mode=spatial width=3 height=3 depth=3 seed=${seed}`)
|
||||
await fixture.wait(() => context.bot.game.gameMode === 'creative', 'Jigsaw Studio did not enter creative mode')
|
||||
context.expect(await exists(path.join(fixture.pack, 'structures', `${structure}.json`)), 'Jigsaw creation omitted the structure resource')
|
||||
const status = await context.command('/iris jigsaw status', /mode=SPATIAL.*variants=7/, 10000)
|
||||
context.report.iris.jigsaw = { structure, createdStatus: status }
|
||||
const opened = context.waitForEvent('windowOpen', window => window.inventoryStart === 54, 10000)
|
||||
await context.command('/iris jigsaw menu')
|
||||
const [window] = await opened
|
||||
context.expect(window.slots.slice(0, 54).some(Boolean), 'Jigsaw controls inventory was empty')
|
||||
await context.bot.closeWindow(window)
|
||||
})
|
||||
await context.step('Rename the selected variant and persist its authored metadata', async () => {
|
||||
await context.command('/iris jigsaw goto workcell/spatial', /Selected Jigsaw Studio workcell 'workcell\/spatial'\./, 10000)
|
||||
const selection = await context.command('/iris jigsaw status', /selected=.*activeVariant=/, 10000)
|
||||
const selected = /selected=([^,]+), activeVariant=([^,]+)/.exec(selection)
|
||||
context.expect(selected && selected[1] !== 'none' && selected[2] !== 'none', 'Jigsaw did not select an owned variant', { selection })
|
||||
const label = 'BotAcceptanceVariant'
|
||||
await context.command(`/iris jigsaw variant label ${label}`, /Renamed the variant to 'BotAcceptanceVariant'\./, 60000)
|
||||
const files = await jsonFiles(path.join(fixture.pack, 'jigsaw-pieces'))
|
||||
const saved = await fixture.wait(async () => {
|
||||
for (const file of files) {
|
||||
const content = await readFile(file, 'utf8')
|
||||
if (content.includes(label)) return { file, content }
|
||||
}
|
||||
return false
|
||||
}, 'Variant rename did not persist through the public authoring command', 60000)
|
||||
context.report.iris.jigsaw.variantFile = path.relative(fixture.pack, saved.file)
|
||||
context.report.iris.jigsaw.label = label
|
||||
await closeStudio(context, fixture, active, true)
|
||||
active = undefined
|
||||
const bytes = await readFile(saved.file, 'utf8')
|
||||
active = await openStudio(context, fixture, `/iris jigsaw open ${packKey} ${structure} seed=${seed}`)
|
||||
context.expect(await readFile(saved.file, 'utf8') === bytes, 'Jigsaw reopen rewrote authored variant metadata')
|
||||
await context.command('/iris jigsaw status', /mode=SPATIAL.*variants=7/, 10000)
|
||||
const ready = await context.command('/iris jigsaw preview assemble seed=1337', /Showing a [1-9]\d*-piece particle preview for 10 seconds\./, 60000)
|
||||
context.report.iris.jigsaw.previewResponse = ready
|
||||
})
|
||||
} finally {
|
||||
if (active) await closeStudio(context, fixture, active, true)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTools(context, fixture, phase) {
|
||||
context.expect(fixture.state.studioCompleted, 'Complete the Studio acceptance phase before object or Jigsaw authoring')
|
||||
if (phase === 'object') return objectTools(context, fixture)
|
||||
if (phase === 'jigsaw') return jigsawTools(context, fixture)
|
||||
throw new Error(`Unknown Studio tools phase: ${phase}`)
|
||||
}
|
||||
@@ -52,6 +52,11 @@ public final class ModdedRegistries implements PlatformRegistries {
|
||||
return generationRegistry.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeBlockState decodeBlockState(String key) {
|
||||
return ModdedBlockResolution.BLOCKS.decode(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeBlockState block(String key) {
|
||||
return ModdedBlockResolution.BLOCKS.get(key);
|
||||
|
||||
+34
@@ -2,6 +2,8 @@ package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockResolver;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
|
||||
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeStateMerger;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeStateRotator;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
@@ -14,6 +16,10 @@ import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NativeStateOperationsTest {
|
||||
@BeforeClass
|
||||
@@ -22,6 +28,34 @@ public class NativeStateOperationsTest {
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistedLeafPropertiesBypassGenerationDefaults() {
|
||||
NativeBlockResolver.Policy policy = mock(NativeBlockResolver.Policy.class);
|
||||
NativeBlockResolver resolver = new NativeBlockResolver(policy);
|
||||
ModdedRegistries registries = new ModdedRegistries(mock(NativeRegistryAccess.class),
|
||||
() -> mock(PlatformGenerationRegistry.class));
|
||||
for (String key : new String[]{
|
||||
"minecraft:oak_leaves[distance=2,persistent=false,waterlogged=true]",
|
||||
"minecraft:oak_leaves[distance=6,persistent=true,waterlogged=false]",
|
||||
"minecraft:oak_stairs[facing=west,half=top,shape=inner_left,waterlogged=true]"}) {
|
||||
assertEquals(NativeBlockResolver.strictParse(key).key(), resolver.decode(key).key());
|
||||
assertEquals(NativeBlockResolver.strictParse(key).key(), registries.decodeBlockState(key).key());
|
||||
}
|
||||
verifyNoInteractions(policy);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistedCustomStateRetainsDeferredPlacementIdentity() {
|
||||
String key = "test:persisted_stairs[facing=west]";
|
||||
ModdedBlockState nativeState = NativeBlockResolver.strictParse("minecraft:oak_stairs[facing=west]");
|
||||
ModdedBlockState custom = nativeState.withDeferredPlacement(key);
|
||||
NativeBlockResolver.Policy policy = mock(NativeBlockResolver.Policy.class);
|
||||
when(policy.resolveCustomBlock(key)).thenReturn(custom);
|
||||
|
||||
assertSame(custom, new NativeBlockResolver(policy).decode(key));
|
||||
assertEquals(key, custom.deferredPlacementKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rotationPreservesDeferredPlacementMetadata() {
|
||||
ModdedBlockState parsed = NativeBlockResolver.strictParse("minecraft:oak_stairs[facing=north]");
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
final class HydrologyCacheBudget {
|
||||
private static final long MEBIBYTE = 1024L * 1024L;
|
||||
private static final HydrologyCacheBudget REGIONAL_RUNTIME = forHeap(Runtime.getRuntime().maxMemory(), true);
|
||||
private static final HydrologyCacheBudget LOCAL_RUNTIME = forHeap(Runtime.getRuntime().maxMemory(), false);
|
||||
private final long plannerBytes;
|
||||
private final long publicationBytes;
|
||||
private final boolean regionalEnabled;
|
||||
|
||||
private HydrologyCacheBudget(long retainedBytes, boolean regionalEnabled) {
|
||||
plannerBytes = retainedBytes / 2L;
|
||||
publicationBytes = retainedBytes - plannerBytes;
|
||||
this.regionalEnabled = regionalEnabled;
|
||||
}
|
||||
|
||||
static HydrologyCacheBudget runtime() {
|
||||
return REGIONAL_RUNTIME;
|
||||
}
|
||||
|
||||
static HydrologyCacheBudget runtime(boolean regionalEnabled) {
|
||||
return regionalEnabled ? REGIONAL_RUNTIME : LOCAL_RUNTIME;
|
||||
}
|
||||
|
||||
static HydrologyCacheBudget forHeap(long maximumHeapBytes, boolean regionalEnabled) {
|
||||
if (maximumHeapBytes <= 0L) {
|
||||
throw new IllegalArgumentException("Maximum heap size must be positive.");
|
||||
}
|
||||
return new HydrologyCacheBudget(Math.clamp(maximumHeapBytes / 8L, 8L * MEBIBYTE, 256L * MEBIBYTE), regionalEnabled);
|
||||
}
|
||||
|
||||
static long sharedStudioBytes() {
|
||||
return Math.clamp(Runtime.getRuntime().maxMemory() / 128L, MEBIBYTE, 16L * MEBIBYTE);
|
||||
}
|
||||
|
||||
long tileBytes() {
|
||||
return publicationBytes * 3L / 4L;
|
||||
}
|
||||
|
||||
long columnBytes() {
|
||||
return publicationBytes / 8L;
|
||||
}
|
||||
|
||||
long diagnosticBytes() {
|
||||
return publicationBytes / 8L;
|
||||
}
|
||||
|
||||
long routingBytes() {
|
||||
return regionalEnabled ? plannerBytes / 8L : plannerBytes * 3L / 16L;
|
||||
}
|
||||
|
||||
long ownerBytes() {
|
||||
return regionalEnabled ? plannerBytes / 4L : plannerBytes * 3L / 4L;
|
||||
}
|
||||
|
||||
long edgeBytes() {
|
||||
return plannerBytes / 16L;
|
||||
}
|
||||
|
||||
long regionalDraftBytes() {
|
||||
return regionalEnabled ? plannerBytes / 8L : 0L;
|
||||
}
|
||||
|
||||
long regionalCoarseBytes() {
|
||||
return regionalEnabled ? plannerBytes / 8L : 0L;
|
||||
}
|
||||
|
||||
long regionalTerrainBytes() {
|
||||
return regionalEnabled ? plannerBytes / 4L : 0L;
|
||||
}
|
||||
|
||||
long regionalReachBytes() {
|
||||
return regionalEnabled ? plannerBytes / 16L : 0L;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
final class HydrologyCacheWeights {
|
||||
private static final long ENTRY_BYTES = 192L;
|
||||
private static final long TERRAIN_BYTES = 256L;
|
||||
private static final long POINT_BYTES = 40L;
|
||||
|
||||
private HydrologyCacheWeights() {
|
||||
}
|
||||
|
||||
static int bounded(long estimatedBytes, long budget, int maximumEntries) {
|
||||
return saturated(Math.max(estimatedBytes, Math.ceilDiv(budget, maximumEntries)));
|
||||
}
|
||||
|
||||
static int bounded(long estimatedBytes, long budget, int maximumEntries, int units, int maximumUnits) {
|
||||
return bounded(Math.max(estimatedBytes, Math.ceilDiv((long) units * budget, maximumUnits)), budget, maximumEntries);
|
||||
}
|
||||
|
||||
static int tile(HydrologyTile tile) {
|
||||
long bytes = network(tile.nodes(), tile.edges(), tile.outlets(), tile.courses(), tile.cavePlans());
|
||||
bytes += diagnostics(tile.localDiagnosticCandidates());
|
||||
bytes += (long) tile.regionalCourseIds().size() * 64L;
|
||||
RiverFootprint footprint = tile.footprint();
|
||||
if (footprint != null) {
|
||||
bytes += (long) footprint.size() * 64L;
|
||||
for (HydrologyColumnSample sample : footprint.columns().values()) {
|
||||
bytes += column(sample);
|
||||
}
|
||||
}
|
||||
return saturated(bytes);
|
||||
}
|
||||
|
||||
static int column(HydrologyColumnSample sample) {
|
||||
return saturated(96L + (long) sample.layers().size() * 192L);
|
||||
}
|
||||
|
||||
static int diagnostics(List<HydrologyDiagnosticCandidate> diagnostics) {
|
||||
return saturated(ENTRY_BYTES + (long) diagnostics.size() * 112L);
|
||||
}
|
||||
|
||||
static int routing(SourceRoutingContext context) {
|
||||
return saturated(ENTRY_BYTES + grid(context.grid()) + routingPlan(context.surfaceRouting())
|
||||
+ routingPlan(context.undergroundRouting()) + diagnostics(context.diagnostics()));
|
||||
}
|
||||
|
||||
static int owner(CrossTileResolvedOwner owner) {
|
||||
HydrologyCaveCourseFilter.Result result = owner.draft().result();
|
||||
long bytes = network(result.nodes(), result.edges(), result.outlets(), result.courses(), result.cavePlans())
|
||||
+ diagnostics(owner.draft().diagnostics());
|
||||
for (CrossTileRejectedCourse rejection : owner.observedRejections()) {
|
||||
bytes += 48L + course(rejection.course());
|
||||
}
|
||||
return saturated(bytes);
|
||||
}
|
||||
|
||||
static int points(List<HydrologyPoint> points) {
|
||||
return saturated(ENTRY_BYTES + (long) points.size() * POINT_BYTES);
|
||||
}
|
||||
|
||||
static int regionalNetwork(HydrologyRegionalNetwork network) {
|
||||
return saturated(network(network.nodes(), network.edges(), network.outlets(), network.courses(), network.cavePlans())
|
||||
+ diagnostics(network.diagnostics()));
|
||||
}
|
||||
|
||||
static int regionalCoarse(HydrologyRegionalCoarsePlan plan) {
|
||||
HydrologyRegionalGraph.Tree tree = plan.tree();
|
||||
long treeBytes = tree == null ? 0L : 96L + (long) tree.selected().length * 8L
|
||||
+ (long) tree.accumulation().length * 4L + tree.retainedLabelCount() * 96L;
|
||||
return saturated(ENTRY_BYTES + grid(plan.grid()) + treeBytes + (long) plan.outlets().size() * 224L
|
||||
+ (long) plan.sources().size() * 32L + (long) plan.envelopes().size() * 64L);
|
||||
}
|
||||
|
||||
static int terrain(HydrologyTerrainSample terrain) {
|
||||
return saturated(128L + (terrain == null ? 0L : TERRAIN_BYTES));
|
||||
}
|
||||
|
||||
private static long grid(HydrologySampledGrid grid) {
|
||||
return grid == null ? 0L : 96L + (long) grid.nodes().size() * (64L + TERRAIN_BYTES);
|
||||
}
|
||||
|
||||
private static long routingPlan(HydrologyRoutingPlan plan) {
|
||||
if (plan == null) {
|
||||
return 0L;
|
||||
}
|
||||
return 128L + (long) plan.potential().length * 8L
|
||||
+ ((long) plan.parent().length + plan.outletIndex().length + plan.routeLengths().length) * 4L
|
||||
+ (long) plan.outlets().size() * 224L;
|
||||
}
|
||||
|
||||
private static long network(List<DrainageNode> nodes, List<DrainageEdge> edges, List<RiverOutlet> outlets,
|
||||
List<RiverCourse> courses, List<HydrologyCavePlan> cavePlans) {
|
||||
long bytes = ENTRY_BYTES + (long) nodes.size() * (96L + TERRAIN_BYTES) + (long) outlets.size() * 224L;
|
||||
for (DrainageEdge edge : edges) {
|
||||
bytes += 96L + points(edge.centerline());
|
||||
}
|
||||
for (RiverCourse course : courses) {
|
||||
bytes += course(course);
|
||||
}
|
||||
for (HydrologyCavePlan plan : cavePlans) {
|
||||
bytes += plan.estimatedRetainedBytes();
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static long course(RiverCourse course) {
|
||||
long bytes = 128L;
|
||||
for (DrainageEdge edge : course.drainageEdges()) {
|
||||
bytes += 96L + points(edge.centerline());
|
||||
}
|
||||
for (HydraulicSegment segment : course.segments()) {
|
||||
bytes += 128L + points(segment.centerline()) + (long) segment.channelProfile().size() * 16L;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static int saturated(long bytes) {
|
||||
return (int) Math.clamp(bytes, 1L, Integer.MAX_VALUE);
|
||||
}
|
||||
}
|
||||
+18
-4
@@ -20,6 +20,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ForkJoinWorkerThread;
|
||||
|
||||
final class HydrologyCrossTileResolver {
|
||||
private static final int FALLBACK_TRIAL_PARALLELISM = 16;
|
||||
@@ -283,11 +284,20 @@ final class HydrologyCrossTileResolver {
|
||||
List<OutletCandidate> outlets,
|
||||
int start
|
||||
) {
|
||||
int end = Math.min(outlets.size(), start + FALLBACK_TRIAL_PARALLELISM);
|
||||
int parallelism = HydrologyPlanningAdmission.effectiveParallelism(
|
||||
Thread.currentThread() instanceof ForkJoinWorkerThread worker
|
||||
? worker.getPool().getParallelism()
|
||||
: IrisPlatforms.isBound() ? MultiBurst.hydrology.parallelism() : 1);
|
||||
int batchSize = parallelism <= 2 ? 1 : Math.max(1, Math.min(HydrologyPlanningAdmission.maximumTrials(),
|
||||
Math.min(FALLBACK_TRIAL_PARALLELISM, parallelism)));
|
||||
int end = Math.min(outlets.size(), start + batchSize);
|
||||
ArrayList<Callable<FallbackTrial>> tasks = new ArrayList<>(end - start);
|
||||
HydrologyPlanner.PlanningSamples ownerSamples = planner.planningSamples.get();
|
||||
HydrologyLandHeightCache sharedLandHeights = planner.naturalSampler == null ? null
|
||||
: ownerSamples == null ? new HydrologyLandHeightCache() : ownerSamples.fallbackLandHeights();
|
||||
for (int index = start; index < end; index++) {
|
||||
OutletCandidate outlet = outlets.get(index);
|
||||
tasks.add(() -> compileFallbackTrial(context, outlet));
|
||||
tasks.add(() -> compileFallbackTrial(context, outlet, sharedLandHeights));
|
||||
}
|
||||
List<FallbackTrial> trials = HydrologyForkJoin.invokeAll(tasks,
|
||||
IrisPlatforms.isBound() ? MultiBurst.hydrology : null);
|
||||
@@ -305,7 +315,11 @@ final class HydrologyCrossTileResolver {
|
||||
return trials;
|
||||
}
|
||||
|
||||
private FallbackTrial compileFallbackTrial(FallbackContext context, OutletCandidate outlet) {
|
||||
private FallbackTrial compileFallbackTrial(
|
||||
FallbackContext context,
|
||||
OutletCandidate outlet,
|
||||
HydrologyLandHeightCache sharedLandHeights
|
||||
) {
|
||||
HydrologyPlanner.PlanningSamples previousSamples = planner.planningSamples.get();
|
||||
DraftProfile previousProfile = planner.draftProfiles.get();
|
||||
DraftProfile profile = new DraftProfile();
|
||||
@@ -314,7 +328,7 @@ final class HydrologyCrossTileResolver {
|
||||
HashMap<SourceCompilationKey, SourceCompilation> sourceCompilations = new HashMap<>(context.sourceCompilations());
|
||||
HydrologyFootprintCompiler footprints = null;
|
||||
try {
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(sharedLandHeights));
|
||||
planner.draftProfiles.set(profile);
|
||||
footprints = new HydrologyFootprintCompiler(
|
||||
planner.settings,
|
||||
|
||||
+1
-1
@@ -391,7 +391,7 @@ final class HydrologyFootprintCompiler {
|
||||
}
|
||||
|
||||
RiverFootprint build(Long2ObjectLinkedOpenHashMap<FootprintMutableColumn> columns) {
|
||||
LinkedHashMap<Long, HydrologyColumnSample> immutable = new LinkedHashMap<>();
|
||||
Long2ObjectOpenHashMap<HydrologyColumnSample> immutable = new Long2ObjectOpenHashMap<>(columns.size());
|
||||
for (Long2ObjectMap.Entry<FootprintMutableColumn> entry : columns.long2ObjectEntrySet()) {
|
||||
immutable.put(entry.getLongKey(), entry.getValue().build());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.volmlib.util.cache.CacheKey;
|
||||
import it.unimi.dsi.fastutil.longs.Long2DoubleLinkedOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2DoubleMap;
|
||||
|
||||
final class HydrologyLandHeightCache {
|
||||
private static final int MAXIMUM_ENTRIES = 131_072;
|
||||
private static final int MAXIMUM_STRIPES = 64;
|
||||
private final Stripe[] stripes;
|
||||
|
||||
HydrologyLandHeightCache() {
|
||||
this(MAXIMUM_ENTRIES);
|
||||
}
|
||||
|
||||
HydrologyLandHeightCache(int maximumEntries) {
|
||||
if (maximumEntries < 1 || Integer.bitCount(maximumEntries) != 1) {
|
||||
throw new IllegalArgumentException("Land height cache size must be a positive power of two.");
|
||||
}
|
||||
int stripeCount = Math.min(MAXIMUM_STRIPES, maximumEntries);
|
||||
stripes = new Stripe[stripeCount];
|
||||
for (int index = 0; index < stripeCount; index++) {
|
||||
stripes[index] = new Stripe(maximumEntries / stripeCount);
|
||||
}
|
||||
}
|
||||
|
||||
double sample(int blockX, int blockZ, HydrologyNaturalTerrainSampler sampler) {
|
||||
long packed = RiverFootprint.pack(blockX, blockZ);
|
||||
Stripe stripe = stripe(packed);
|
||||
synchronized (stripe) {
|
||||
double cached = stripe.heights.getAndMoveToFirst(packed);
|
||||
if (!Double.isNaN(cached) || stripe.heights.containsKey(packed)) {
|
||||
return cached;
|
||||
}
|
||||
double sampled = sampler.sampleLandHeight(blockX, blockZ);
|
||||
stripe.put(packed, sampled);
|
||||
return sampled;
|
||||
}
|
||||
}
|
||||
|
||||
void seed(Long2DoubleMap samples) {
|
||||
for (Long2DoubleMap.Entry entry : samples.long2DoubleEntrySet()) {
|
||||
Stripe stripe = stripe(entry.getLongKey());
|
||||
synchronized (stripe) {
|
||||
stripe.put(entry.getLongKey(), entry.getDoubleValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Stripe stripe(long packed) {
|
||||
return stripes[(int) CacheKey.mix(packed) & (stripes.length - 1)];
|
||||
}
|
||||
|
||||
private static final class Stripe {
|
||||
private final int maximumEntries;
|
||||
private final Long2DoubleLinkedOpenHashMap heights;
|
||||
|
||||
private Stripe(int maximumEntries) {
|
||||
this.maximumEntries = maximumEntries;
|
||||
heights = new Long2DoubleLinkedOpenHashMap(maximumEntries);
|
||||
heights.defaultReturnValue(Double.NaN);
|
||||
}
|
||||
|
||||
private void put(long packed, double height) {
|
||||
if (heights.size() >= maximumEntries && !heights.containsKey(packed)) {
|
||||
heights.removeLastDouble();
|
||||
}
|
||||
heights.putAndMoveToFirst(packed, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -6,4 +6,9 @@ public interface HydrologyNaturalTerrainSampler extends HydrologyRoutingTerrainS
|
||||
default HydrologyTerrainSample sampleBasisWithoutSlope(int blockX, int blockZ) {
|
||||
return sampleBasis(blockX, blockZ);
|
||||
}
|
||||
|
||||
default double sampleLandHeight(int blockX, int blockZ) {
|
||||
HydrologyTerrainSample terrain = sampleBasisWithoutSlope(blockX, blockZ);
|
||||
return terrain == null || terrain.ocean() ? Double.NaN : terrain.naturalHeight();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import art.arcane.volmlib.util.noise.SimplexNoise;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
@@ -162,15 +163,22 @@ public final class HydrologyPlanner {
|
||||
this.geometrySampler = Objects.requireNonNull(geometrySampler, "geometrySampler");
|
||||
this.minimumY = minimumY;
|
||||
this.caveViewFactory = Objects.requireNonNull(caveViewFactory, "caveViewFactory");
|
||||
HydrologyCacheBudget cacheBudget = HydrologyCacheBudget.runtime(settings.routing().regional().enabled());
|
||||
this.routingContexts = Caffeine.newBuilder()
|
||||
.maximumSize(ROUTING_CONTEXT_CACHE_SIZE)
|
||||
.maximumWeight(cacheBudget.routingBytes())
|
||||
.weigher((HydrologyTileKey key, SourceRoutingContext context) -> HydrologyCacheWeights.bounded(
|
||||
HydrologyCacheWeights.routing(context), cacheBudget.routingBytes(), ROUTING_CONTEXT_CACHE_SIZE))
|
||||
.build();
|
||||
this.resolvingRoutingContexts = new ConcurrentHashMap<>();
|
||||
this.resolvedOwners = Caffeine.newBuilder()
|
||||
.maximumSize(RESOLVED_OWNER_CACHE_SIZE)
|
||||
.maximumWeight(cacheBudget.ownerBytes())
|
||||
.weigher((HydrologyTileKey key, CrossTileResolvedOwner owner) -> HydrologyCacheWeights.bounded(
|
||||
HydrologyCacheWeights.owner(owner), cacheBudget.ownerBytes(), RESOLVED_OWNER_CACHE_SIZE))
|
||||
.build();
|
||||
this.refinedEdgeCache = Caffeine.newBuilder()
|
||||
.maximumSize(REFINED_EDGE_CACHE_SIZE)
|
||||
.maximumWeight(cacheBudget.edgeBytes())
|
||||
.weigher((RefinedEdgeKey key, List<HydrologyPoint> points) -> HydrologyCacheWeights.bounded(
|
||||
HydrologyCacheWeights.points(points), cacheBudget.edgeBytes(), REFINED_EDGE_CACHE_SIZE))
|
||||
.build();
|
||||
this.resolvingOwners = new ConcurrentHashMap<>();
|
||||
this.planningSamples = new ThreadLocal<>();
|
||||
@@ -304,7 +312,7 @@ public final class HydrologyPlanner {
|
||||
maximumCrossTileDependencyOffset(key),
|
||||
maximumCrossTileDependencyOwners(key)
|
||||
);
|
||||
CrossTileResolvedOwner resolved = resolveCrossTileOwner(key, context);
|
||||
CrossTileResolvedOwner resolved = resolveCrossTileOwner(key, context, true);
|
||||
return new CrossTileResolution(
|
||||
resolved.draft(),
|
||||
resolved.observedRejections(),
|
||||
@@ -316,7 +324,8 @@ public final class HydrologyPlanner {
|
||||
|
||||
CrossTileResolvedOwner resolveCrossTileOwner(
|
||||
HydrologyTileKey key,
|
||||
CrossTileResolutionContext context
|
||||
CrossTileResolutionContext context,
|
||||
boolean retainFootprintCompiler
|
||||
) {
|
||||
CrossTileResolvedOwner local = context.resolved(key);
|
||||
if (local != null) {
|
||||
@@ -347,7 +356,7 @@ public final class HydrologyPlanner {
|
||||
draft = crossTile.compileOwnerDraft(
|
||||
key,
|
||||
admission,
|
||||
key.equals(context.root())
|
||||
retainFootprintCompiler
|
||||
);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
admission.primaryFailure = failure;
|
||||
@@ -379,7 +388,7 @@ public final class HydrologyPlanner {
|
||||
for (HydrologyTileKey candidateKey : candidateKeys) {
|
||||
HydrologyForkJoin.Task<CrossTileResolvedOwner> prepared = preparedOwners.get(candidateKey);
|
||||
CrossTileResolvedOwner owner = prepared == null
|
||||
? resolveCrossTileOwner(candidateKey, context) : prepared.await();
|
||||
? resolveCrossTileOwner(candidateKey, context, false) : prepared.await();
|
||||
context.remember(candidateKey, owner);
|
||||
resolved.add(owner);
|
||||
}
|
||||
@@ -408,7 +417,7 @@ public final class HydrologyPlanner {
|
||||
maximumCrossTileDependencyOffset(key),
|
||||
maximumCrossTileDependencyOwners(key)
|
||||
);
|
||||
return resolveCrossTileOwner(key, context);
|
||||
return resolveCrossTileOwner(key, context, false);
|
||||
}
|
||||
|
||||
static CrossTileResolvedOwner awaitResolvedOwner(
|
||||
@@ -578,6 +587,35 @@ public final class HydrologyPlanner {
|
||||
return terrain == null || terrain.ocean() ? null : terrain;
|
||||
}
|
||||
|
||||
double sampleLandHeight(int blockX, int blockZ) {
|
||||
if (naturalSampler == null) {
|
||||
HydrologyTerrainSample terrain = sampleLandBasisWithoutSlope(blockX, blockZ);
|
||||
return terrain == null ? Double.NaN : terrain.naturalHeight();
|
||||
}
|
||||
PlanningSamples samples = planningSamples.get();
|
||||
if (samples == null) {
|
||||
return naturalSampler.sampleLandHeight(blockX, blockZ);
|
||||
}
|
||||
long packed = RiverFootprint.pack(blockX, blockZ);
|
||||
HydrologyTerrainSample terrain = samples.basis.get(packed);
|
||||
if (terrain != null || samples.basis.containsKey(packed)) {
|
||||
return terrain == null || terrain.ocean() ? Double.NaN : terrain.naturalHeight();
|
||||
}
|
||||
terrain = samples.basisWithoutSlope.get(packed);
|
||||
if (terrain != null || samples.basisWithoutSlope.containsKey(packed)) {
|
||||
return terrain == null || terrain.ocean() ? Double.NaN : terrain.naturalHeight();
|
||||
}
|
||||
double cached = samples.landHeights.get(packed);
|
||||
if (!Double.isNaN(cached) || samples.landHeights.containsKey(packed)) {
|
||||
return cached;
|
||||
}
|
||||
double sampled = samples.sharedLandHeights == null
|
||||
? naturalSampler.sampleLandHeight(blockX, blockZ)
|
||||
: samples.sharedLandHeights.sample(blockX, blockZ, naturalSampler);
|
||||
samples.landHeights.put(packed, sampled);
|
||||
return sampled;
|
||||
}
|
||||
|
||||
HydrologyTerrainSample sampleDetailed(int blockX, int blockZ) {
|
||||
PlanningSamples samples = planningSamples.get();
|
||||
if (samples == null) {
|
||||
@@ -618,6 +656,11 @@ public final class HydrologyPlanner {
|
||||
this.sampler = Objects.requireNonNull(sampler, "sampler");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSharedGridSamples() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
|
||||
int minimumX = request.minimumX();
|
||||
@@ -648,6 +691,8 @@ public final class HydrologyPlanner {
|
||||
static final class PlanningSamples {
|
||||
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> basis;
|
||||
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> basisWithoutSlope;
|
||||
private final Long2DoubleOpenHashMap landHeights;
|
||||
private HydrologyLandHeightCache sharedLandHeights;
|
||||
final Long2ObjectOpenHashMap<HydrologyTerrainSample> detailed;
|
||||
private final Long2ObjectOpenHashMap<HydrologyRoutingTerrainSampler.NaturalClassification> classifications;
|
||||
private final HashMap<HydrologyGeometrySampler.Request, Integer> geometry;
|
||||
@@ -661,8 +706,15 @@ public final class HydrologyPlanner {
|
||||
final HydrologyCaveContainmentPlanner.ValidationCache caveValidations;
|
||||
|
||||
PlanningSamples() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
PlanningSamples(HydrologyLandHeightCache sharedLandHeights) {
|
||||
this.sharedLandHeights = sharedLandHeights;
|
||||
this.basis = new Long2ObjectOpenHashMap<>();
|
||||
this.basisWithoutSlope = new Long2ObjectOpenHashMap<>();
|
||||
this.landHeights = new Long2DoubleOpenHashMap();
|
||||
this.landHeights.defaultReturnValue(Double.NaN);
|
||||
this.detailed = new Long2ObjectOpenHashMap<>();
|
||||
this.classifications = new Long2ObjectOpenHashMap<>();
|
||||
this.geometry = new HashMap<>();
|
||||
@@ -675,6 +727,14 @@ public final class HydrologyPlanner {
|
||||
this.caveCandidates = new HydrologyCaveCourseFilter.CandidateCache();
|
||||
this.caveValidations = new HydrologyCaveContainmentPlanner.ValidationCache();
|
||||
}
|
||||
|
||||
HydrologyLandHeightCache fallbackLandHeights() {
|
||||
if (sharedLandHeights == null) {
|
||||
sharedLandHeights = new HydrologyLandHeightCache();
|
||||
sharedLandHeights.seed(landHeights);
|
||||
}
|
||||
return sharedLandHeights;
|
||||
}
|
||||
}
|
||||
|
||||
final class ColorRankedDraftAdmission implements CrossTileDraftAdmission, AutoCloseable {
|
||||
@@ -702,7 +762,8 @@ public final class HydrologyPlanner {
|
||||
@Override
|
||||
public void prepare() {
|
||||
if (ownerRank == 0 || settings.crossTileColorPeriod() != 2 || !sourcePlanner.hasRoutedSourceSearch()
|
||||
|| !(Thread.currentThread() instanceof ForkJoinWorkerThread worker)) {
|
||||
|| !(Thread.currentThread() instanceof ForkJoinWorkerThread worker)
|
||||
|| HydrologyPlanningAdmission.effectiveParallelism(worker.getPool().getParallelism()) <= 2) {
|
||||
return;
|
||||
}
|
||||
int radius = settings.crossTileColorPeriod() - 1;
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
final class HydrologyPlanningAdmission {
|
||||
private static final long BYTES_PER_ROOT = 2L * 1024L * 1024L * 1024L;
|
||||
private static final int MAXIMUM_ROOTS = rootLimit(
|
||||
Runtime.getRuntime().maxMemory(), Runtime.getRuntime().availableProcessors());
|
||||
private static final int MAXIMUM_TRIALS = trialLimit(Runtime.getRuntime().maxMemory(), MAXIMUM_ROOTS);
|
||||
private static final HydrologyPlanningAdmission ROOTS = new HydrologyPlanningAdmission(MAXIMUM_ROOTS);
|
||||
|
||||
private final Semaphore permits;
|
||||
|
||||
HydrologyPlanningAdmission(int maximumRoots) {
|
||||
if (maximumRoots < 1) {
|
||||
throw new IllegalArgumentException("Maximum hydrology roots must be positive.");
|
||||
}
|
||||
permits = new Semaphore(maximumRoots, true);
|
||||
}
|
||||
|
||||
static Permit acquireRoot(BooleanSupplier cancelled) {
|
||||
return ROOTS.acquire(cancelled);
|
||||
}
|
||||
|
||||
static int maximumRoots() {
|
||||
return MAXIMUM_ROOTS;
|
||||
}
|
||||
|
||||
static int maximumTrials() {
|
||||
return MAXIMUM_TRIALS;
|
||||
}
|
||||
|
||||
static int effectiveParallelism(int poolParallelism) {
|
||||
return effectiveParallelism(poolParallelism, Runtime.getRuntime().availableProcessors());
|
||||
}
|
||||
|
||||
static int effectiveParallelism(int poolParallelism, int availableProcessors) {
|
||||
return Math.max(1, Math.min(poolParallelism, availableProcessors));
|
||||
}
|
||||
|
||||
static int trialLimit(long maximumHeap, int maximumRoots) {
|
||||
if (maximumRoots < 1) {
|
||||
throw new IllegalArgumentException("Maximum hydrology roots must be positive.");
|
||||
}
|
||||
return (int) Math.clamp(maximumHeap / maximumRoots / (1024L * 1024L * 1024L), 1L, 16L);
|
||||
}
|
||||
|
||||
static int rootLimit(long maximumHeap, int processors) {
|
||||
return (int) Math.max(1L, Math.min(Math.min(8L, processors), maximumHeap / BYTES_PER_ROOT));
|
||||
}
|
||||
|
||||
Permit acquire(BooleanSupplier cancelled) {
|
||||
AdmissionWait wait = new AdmissionWait(Objects.requireNonNull(cancelled));
|
||||
try {
|
||||
ForkJoinPool.managedBlock(wait);
|
||||
wait.requireActive();
|
||||
return new Permit(permits);
|
||||
} catch (InterruptedException interruption) {
|
||||
Thread.currentThread().interrupt();
|
||||
if (wait.acquired) {
|
||||
permits.release();
|
||||
}
|
||||
throw new CancellationException("Hydrology planning admission interrupted.");
|
||||
} catch (RuntimeException | Error failure) {
|
||||
if (wait.acquired) {
|
||||
permits.release();
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
static final class Permit implements AutoCloseable {
|
||||
private final Semaphore permits;
|
||||
private final AtomicBoolean released = new AtomicBoolean();
|
||||
|
||||
private Permit(Semaphore permits) {
|
||||
this.permits = permits;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (released.compareAndSet(false, true)) {
|
||||
permits.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class AdmissionWait implements ForkJoinPool.ManagedBlocker {
|
||||
private final BooleanSupplier cancelled;
|
||||
private boolean acquired;
|
||||
|
||||
private AdmissionWait(BooleanSupplier cancelled) {
|
||||
this.cancelled = cancelled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReleasable() {
|
||||
requireActive();
|
||||
if (!acquired) {
|
||||
try {
|
||||
acquired = permits.tryAcquire(0L, TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException interruption) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new CancellationException("Hydrology planning admission interrupted.");
|
||||
}
|
||||
}
|
||||
return acquired;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean block() throws InterruptedException {
|
||||
requireActive();
|
||||
if (!acquired) {
|
||||
acquired = permits.tryAcquire(100L, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
return acquired;
|
||||
}
|
||||
|
||||
private void requireActive() {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw new CancellationException("Hydrology planning admission interrupted.");
|
||||
}
|
||||
if (cancelled.getAsBoolean()) {
|
||||
throw new CancellationException("Hydrology tile cache is closed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.BitSet;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.PriorityQueue;
|
||||
@@ -160,6 +161,20 @@ final class HydrologyRegionalGraph {
|
||||
}
|
||||
|
||||
record Tree(Label[] selected, int[] accumulation, List<OutletCandidate> outlets) {
|
||||
int retainedLabelCount() {
|
||||
BitSet retained = new BitSet(accumulation.length);
|
||||
for (Label label : selected) {
|
||||
for (Label current = label; current != null; current = current.downstream) {
|
||||
int id = current.state.id();
|
||||
if (retained.get(id)) {
|
||||
break;
|
||||
}
|
||||
retained.set(id);
|
||||
}
|
||||
}
|
||||
return retained.cardinality();
|
||||
}
|
||||
|
||||
int root(int source) {
|
||||
return selected[source] == null ? -1 : selected[source].state.root();
|
||||
}
|
||||
|
||||
+18
-8
@@ -51,14 +51,18 @@ final class HydrologyRegionalPlanner {
|
||||
this.settings = planner.settings.routing().regional();
|
||||
this.graph = new HydrologyRegionalGraph(planner);
|
||||
this.routes = new HydrologyRegionalRoute(planner);
|
||||
int minimumWeight = Math.max(1, Math.ceilDiv(settings.maximumCachedStations(), settings.maximumCachedBasins()));
|
||||
HydrologyCacheBudget cacheBudget = HydrologyCacheBudget.runtime(settings.enabled());
|
||||
this.drafts = Caffeine.newBuilder()
|
||||
.maximumWeight(settings.maximumCachedStations())
|
||||
.weigher((HydrologyTileKey key, HydrologyRegionalNetwork network) -> Math.max(minimumWeight, network.stations()))
|
||||
.maximumWeight(cacheBudget.regionalDraftBytes())
|
||||
.weigher((HydrologyTileKey key, HydrologyRegionalNetwork network) -> HydrologyCacheWeights.bounded(
|
||||
HydrologyCacheWeights.regionalNetwork(network), cacheBudget.regionalDraftBytes(),
|
||||
settings.maximumCachedBasins(), network.stations(), settings.maximumCachedStations()))
|
||||
.build();
|
||||
this.coarsePlans = Caffeine.newBuilder()
|
||||
.maximumWeight(settings.maximumCachedStations())
|
||||
.weigher((HydrologyTileKey key, HydrologyRegionalCoarsePlan plan) -> Math.max(minimumWeight, plan.weight()))
|
||||
.maximumWeight(cacheBudget.regionalCoarseBytes())
|
||||
.weigher((HydrologyTileKey key, HydrologyRegionalCoarsePlan plan) -> HydrologyCacheWeights.bounded(
|
||||
HydrologyCacheWeights.regionalCoarse(plan), cacheBudget.regionalCoarseBytes(),
|
||||
settings.maximumCachedBasins(), plan.weight(), settings.maximumCachedStations()))
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -347,8 +351,13 @@ final class HydrologyRegionalPlanner {
|
||||
if (tasks.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
List<SourceTrial> completed = HydrologyForkJoin.invokeAll(tasks,
|
||||
IrisPlatforms.isBound() ? MultiBurst.hydrology : null);
|
||||
ArrayList<SourceTrial> completed = new ArrayList<>(tasks.size());
|
||||
int batchSize = HydrologyPlanningAdmission.maximumTrials();
|
||||
for (int start = 0; start < tasks.size(); start += batchSize) {
|
||||
int end = Math.min(tasks.size(), start + batchSize);
|
||||
completed.addAll(HydrologyForkJoin.invokeAll(tasks.subList(start, end),
|
||||
IrisPlatforms.isBound() ? MultiBurst.hydrology : null));
|
||||
}
|
||||
for (SourceTrial trial : completed) {
|
||||
if (accepted.size() >= settings.maximumTrunks()) {
|
||||
break;
|
||||
@@ -642,8 +651,9 @@ final class HydrologyRegionalPlanner {
|
||||
int[] excavation = new int[centerline.size()];
|
||||
HydrologyRegionalConnectivity connectivity = new HydrologyRegionalConnectivity();
|
||||
HydrologyTerrainSampler receiver = HydrologyOceanReceiver.forCourse(planner.settings, this::sample, course);
|
||||
SurfaceFootprintCompiler.PreparedCourse prepared = compiler.prepare(course);
|
||||
for (SurfaceBounds bounds : courseWindows(course)) {
|
||||
SurfaceFootprint footprint = compiler.compile(course, bounds);
|
||||
SurfaceFootprint footprint = compiler.compile(prepared, bounds);
|
||||
if (!footprint.accepted()) {
|
||||
diagnostic(diagnostics, course.id(), source, coastal, footprint.rejection(), footprint.rejectionDetail());
|
||||
return false;
|
||||
|
||||
+20
-5
@@ -23,16 +23,18 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
|
||||
private final HydrologyPlanner planner;
|
||||
private final HydrologyRegionalHydraulics hydraulics;
|
||||
private final HydrologyCacheBudget cacheBudget;
|
||||
private volatile CacheState caches;
|
||||
|
||||
HydrologyRegionalTerrainRefiner(HydrologyPlanner planner) {
|
||||
this.planner = planner;
|
||||
this.hydraulics = new HydrologyRegionalHydraulics(planner.settings);
|
||||
this.caches = new CacheState();
|
||||
this.cacheBudget = HydrologyCacheBudget.runtime(planner.settings.routing().regional().enabled());
|
||||
this.caches = new CacheState(cacheBudget);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
caches = new CacheState();
|
||||
caches = new CacheState(cacheBudget);
|
||||
}
|
||||
|
||||
HydrologyTerrainSample sample(int x, int z) {
|
||||
@@ -459,10 +461,23 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
}
|
||||
|
||||
private static final class CacheState {
|
||||
private final Cache<Edge, Reach> edges = Caffeine.newBuilder().maximumSize(128).build();
|
||||
private final Cache<Edge, Reach> edges;
|
||||
private final ConcurrentHashMap<Edge, HydrologyForkJoin.Task<Reach>> loading = new ConcurrentHashMap<>();
|
||||
private final Cache<Long, Terrain> terrain = Caffeine.newBuilder().maximumSize(Math.max(MAXIMUM_SAMPLES,
|
||||
Math.min(MAXIMUM_CACHED_SAMPLES, Runtime.getRuntime().maxMemory() / 8192L))).build();
|
||||
private final Cache<Long, Terrain> terrain;
|
||||
|
||||
private CacheState(HydrologyCacheBudget budget) {
|
||||
edges = Caffeine.newBuilder()
|
||||
.maximumWeight(budget.regionalReachBytes())
|
||||
.weigher((Edge edge, Reach reach) -> HydrologyCacheWeights.bounded(
|
||||
HydrologyCacheWeights.points(reach.points()), budget.regionalReachBytes(), 128))
|
||||
.build();
|
||||
terrain = Caffeine.newBuilder()
|
||||
.maximumWeight(budget.regionalTerrainBytes())
|
||||
.weigher((Long key, Terrain terrain) -> HydrologyCacheWeights.bounded(
|
||||
HydrologyCacheWeights.terrain(terrain.sample()), budget.regionalTerrainBytes(),
|
||||
(int) Math.max(MAXIMUM_SAMPLES, Math.min(MAXIMUM_CACHED_SAMPLES, Runtime.getRuntime().maxMemory() / 8192L))))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
private record Offer(HydrologyPoint point, double cost, int availableHead) {
|
||||
|
||||
+81
-27
@@ -657,11 +657,11 @@ final class HydrologyRouteGeometry {
|
||||
int lowest = center.naturalHeight();
|
||||
for (double distance = reach / 2D; distance <= reach; distance += reach / 2D) {
|
||||
for (double direction = -1D; direction <= 1D; direction += 2D) {
|
||||
HydrologyTerrainSample side = planner.sampleLandBasisWithoutSlope(
|
||||
double side = planner.sampleLandHeight(
|
||||
(int) StrictMath.round(point.x() + normalX * distance * direction),
|
||||
(int) StrictMath.round(point.z() + normalZ * distance * direction));
|
||||
if (side != null) {
|
||||
lowest = Math.min(lowest, side.naturalHeight());
|
||||
if (!Double.isNaN(side)) {
|
||||
lowest = Math.min(lowest, (int) side);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -819,6 +819,7 @@ final class HydrologyRouteGeometry {
|
||||
Arrays.fill(row, Double.NaN);
|
||||
}
|
||||
}
|
||||
CurvatureWorkspace workspace = new CurvatureWorkspace(layers);
|
||||
while (true) {
|
||||
CurvatureRouteSelection selection = selectCurvatureAwareTerrainRoute(
|
||||
layers,
|
||||
@@ -827,7 +828,8 @@ final class HydrologyRouteGeometry {
|
||||
turnCost,
|
||||
localPenalties,
|
||||
transitionCosts,
|
||||
turnCosts
|
||||
turnCosts,
|
||||
workspace
|
||||
);
|
||||
if (selection.route().length == 0) {
|
||||
return selection.route();
|
||||
@@ -852,17 +854,17 @@ final class HydrologyRouteGeometry {
|
||||
double turnCost,
|
||||
double[][] localPenalties,
|
||||
double[][][] transitionCosts,
|
||||
double[][][][] turnCosts
|
||||
double[][][][] turnCosts,
|
||||
CurvatureWorkspace workspace
|
||||
) {
|
||||
int layerCount = layers.size();
|
||||
double[][][] costs = new double[layerCount][][];
|
||||
int[][][] predecessors = new int[layerCount][][];
|
||||
double[][] previousCosts = workspace.firstCosts;
|
||||
double[][] currentCosts = workspace.secondCosts;
|
||||
int[][][] predecessors = workspace.predecessors;
|
||||
List<RouteCandidate> firstLayer = layers.getFirst();
|
||||
List<RouteCandidate> secondLayer = layers.get(1);
|
||||
costs[1] = new double[firstLayer.size()][secondLayer.size()];
|
||||
predecessors[1] = new int[firstLayer.size()][secondLayer.size()];
|
||||
for (int firstIndex = 0; firstIndex < firstLayer.size(); firstIndex++) {
|
||||
Arrays.fill(costs[1][firstIndex], Double.POSITIVE_INFINITY);
|
||||
Arrays.fill(previousCosts[firstIndex], Double.POSITIVE_INFINITY);
|
||||
Arrays.fill(predecessors[1][firstIndex], -1);
|
||||
for (int secondIndex = 0; secondIndex < secondLayer.size(); secondIndex++) {
|
||||
RouteCandidate first = firstLayer.get(firstIndex);
|
||||
@@ -871,10 +873,14 @@ final class HydrologyRouteGeometry {
|
||||
first,
|
||||
second,
|
||||
targetStepLength,
|
||||
transitionCosts[1][firstIndex][secondIndex]
|
||||
transitionCosts[1][firstIndex][secondIndex],
|
||||
workspace,
|
||||
1,
|
||||
firstIndex,
|
||||
secondIndex
|
||||
);
|
||||
if (Double.isFinite(transition)) {
|
||||
costs[1][firstIndex][secondIndex] = first.localScore()
|
||||
previousCosts[firstIndex][secondIndex] = first.localScore()
|
||||
+ second.localScore()
|
||||
+ resolvedLocalPenalty(localPenalties[1][secondIndex])
|
||||
+ transition;
|
||||
@@ -885,13 +891,11 @@ final class HydrologyRouteGeometry {
|
||||
List<RouteCandidate> beforeLayer = layers.get(layerIndex - 2);
|
||||
List<RouteCandidate> previousLayer = layers.get(layerIndex - 1);
|
||||
List<RouteCandidate> currentLayer = layers.get(layerIndex);
|
||||
costs[layerIndex] = new double[previousLayer.size()][currentLayer.size()];
|
||||
predecessors[layerIndex] = new int[previousLayer.size()][currentLayer.size()];
|
||||
if (turnCosts[layerIndex] == null) {
|
||||
turnCosts[layerIndex] = new double[previousLayer.size()][currentLayer.size()][];
|
||||
}
|
||||
for (int previousIndex = 0; previousIndex < previousLayer.size(); previousIndex++) {
|
||||
Arrays.fill(costs[layerIndex][previousIndex], Double.POSITIVE_INFINITY);
|
||||
Arrays.fill(currentCosts[previousIndex], Double.POSITIVE_INFINITY);
|
||||
Arrays.fill(predecessors[layerIndex][previousIndex], -1);
|
||||
RouteCandidate previous = previousLayer.get(previousIndex);
|
||||
for (int currentIndex = 0; currentIndex < currentLayer.size(); currentIndex++) {
|
||||
@@ -900,14 +904,18 @@ final class HydrologyRouteGeometry {
|
||||
previous,
|
||||
current,
|
||||
targetStepLength,
|
||||
transitionCosts[layerIndex][previousIndex][currentIndex]
|
||||
transitionCosts[layerIndex][previousIndex][currentIndex],
|
||||
workspace,
|
||||
layerIndex,
|
||||
previousIndex,
|
||||
currentIndex
|
||||
);
|
||||
if (!Double.isFinite(transition)) {
|
||||
continue;
|
||||
}
|
||||
double localPenalty = resolvedLocalPenalty(localPenalties[layerIndex][currentIndex]);
|
||||
for (int beforeIndex = 0; beforeIndex < beforeLayer.size(); beforeIndex++) {
|
||||
double previousCost = costs[layerIndex - 1][beforeIndex][previousIndex];
|
||||
double previousCost = previousCosts[beforeIndex][previousIndex];
|
||||
if (!Double.isFinite(previousCost)) {
|
||||
continue;
|
||||
}
|
||||
@@ -937,24 +945,27 @@ final class HydrologyRouteGeometry {
|
||||
+ localPenalty
|
||||
+ transition
|
||||
+ turnPenalty;
|
||||
if (cost < costs[layerIndex][previousIndex][currentIndex]) {
|
||||
costs[layerIndex][previousIndex][currentIndex] = cost;
|
||||
if (cost < currentCosts[previousIndex][currentIndex]) {
|
||||
currentCosts[previousIndex][currentIndex] = cost;
|
||||
predecessors[layerIndex][previousIndex][currentIndex] = beforeIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
double[][] completedCosts = currentCosts;
|
||||
currentCosts = previousCosts;
|
||||
previousCosts = completedCosts;
|
||||
}
|
||||
int lastLayerIndex = layerCount - 1;
|
||||
int selectedPreviousIndex = -1;
|
||||
int selectedCurrentIndex = -1;
|
||||
double selectedCost = Double.POSITIVE_INFINITY;
|
||||
for (int previousIndex = 0; previousIndex < costs[lastLayerIndex].length; previousIndex++) {
|
||||
for (int previousIndex = 0; previousIndex < layers.get(lastLayerIndex - 1).size(); previousIndex++) {
|
||||
for (int currentIndex = 0;
|
||||
currentIndex < costs[lastLayerIndex][previousIndex].length;
|
||||
currentIndex < layers.get(lastLayerIndex).size();
|
||||
currentIndex++) {
|
||||
if (costs[lastLayerIndex][previousIndex][currentIndex] < selectedCost) {
|
||||
selectedCost = costs[lastLayerIndex][previousIndex][currentIndex];
|
||||
if (previousCosts[previousIndex][currentIndex] < selectedCost) {
|
||||
selectedCost = previousCosts[previousIndex][currentIndex];
|
||||
selectedPreviousIndex = previousIndex;
|
||||
selectedCurrentIndex = currentIndex;
|
||||
}
|
||||
@@ -1048,11 +1059,24 @@ final class HydrologyRouteGeometry {
|
||||
RouteCandidate previous,
|
||||
RouteCandidate current,
|
||||
double targetStepLength,
|
||||
double exactCost
|
||||
double exactCost,
|
||||
CurvatureWorkspace workspace,
|
||||
int layerIndex,
|
||||
int previousIndex,
|
||||
int currentIndex
|
||||
) {
|
||||
return Double.isNaN(exactCost)
|
||||
? planner.routePaths.routeTransitionGeometryCost(previous, current, targetStepLength)
|
||||
: exactCost;
|
||||
if (!Double.isNaN(exactCost)) {
|
||||
return exactCost;
|
||||
}
|
||||
long heights = ((long) previous.point().y() << 32) | (current.point().y() & 0xffffffffL);
|
||||
if (!workspace.geometryKnown[layerIndex][previousIndex][currentIndex]
|
||||
|| workspace.geometryHeights[layerIndex][previousIndex][currentIndex] != heights) {
|
||||
workspace.geometryCosts[layerIndex][previousIndex][currentIndex] =
|
||||
planner.routePaths.routeTransitionGeometryCost(previous, current, targetStepLength);
|
||||
workspace.geometryHeights[layerIndex][previousIndex][currentIndex] = heights;
|
||||
workspace.geometryKnown[layerIndex][previousIndex][currentIndex] = true;
|
||||
}
|
||||
return workspace.geometryCosts[layerIndex][previousIndex][currentIndex];
|
||||
}
|
||||
|
||||
double curvatureCost(double turn, double maximumTurn, double turnCost) {
|
||||
@@ -1139,6 +1163,36 @@ final class HydrologyRouteGeometry {
|
||||
return penalty;
|
||||
}
|
||||
|
||||
static final class CurvatureWorkspace {
|
||||
private final double[][] firstCosts;
|
||||
private final double[][] secondCosts;
|
||||
private final int[][][] predecessors;
|
||||
private final double[][][] geometryCosts;
|
||||
private final long[][][] geometryHeights;
|
||||
private final boolean[][][] geometryKnown;
|
||||
|
||||
CurvatureWorkspace(List<? extends List<?>> layers) {
|
||||
int maximumCandidates = 0;
|
||||
for (List<?> layer : layers) {
|
||||
maximumCandidates = Math.max(maximumCandidates, layer.size());
|
||||
}
|
||||
firstCosts = new double[maximumCandidates][maximumCandidates];
|
||||
secondCosts = new double[maximumCandidates][maximumCandidates];
|
||||
predecessors = new int[layers.size()][][];
|
||||
geometryCosts = new double[layers.size()][][];
|
||||
geometryHeights = new long[layers.size()][][];
|
||||
geometryKnown = new boolean[layers.size()][][];
|
||||
for (int layer = 1; layer < layers.size(); layer++) {
|
||||
int previousSize = layers.get(layer - 1).size();
|
||||
int currentSize = layers.get(layer).size();
|
||||
predecessors[layer] = new int[previousSize][currentSize];
|
||||
geometryCosts[layer] = new double[previousSize][currentSize];
|
||||
geometryHeights[layer] = new long[previousSize][currentSize];
|
||||
geometryKnown[layer] = new boolean[previousSize][currentSize];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record CurvatureRouteSelection(RouteCandidate[] route, int[] indices) {
|
||||
private static CurvatureRouteSelection empty() {
|
||||
return new CurvatureRouteSelection(new RouteCandidate[0], new int[0]);
|
||||
|
||||
@@ -63,13 +63,13 @@ final class HydrologyRoutePath {
|
||||
if (offsetX == 0 && offsetZ == 0) {
|
||||
return false;
|
||||
}
|
||||
HydrologyTerrainSample before = planner.sampleLandBasisWithoutSlope(point.x() - offsetX, point.z() - offsetZ);
|
||||
HydrologyTerrainSample after = planner.sampleLandBasisWithoutSlope(point.x() + offsetX, point.z() + offsetZ);
|
||||
double before = planner.sampleLandHeight(point.x() - offsetX, point.z() - offsetZ);
|
||||
double after = planner.sampleLandHeight(point.x() + offsetX, point.z() + offsetZ);
|
||||
int threshold = planner.settings.hydraulics().waterfallMinimumDrop();
|
||||
return before != null
|
||||
&& after != null
|
||||
&& before.naturalHeight() - point.y() >= threshold
|
||||
&& after.naturalHeight() - point.y() >= threshold;
|
||||
return !Double.isNaN(before)
|
||||
&& !Double.isNaN(after)
|
||||
&& (int) before - point.y() >= threshold
|
||||
&& (int) after - point.y() >= threshold;
|
||||
}
|
||||
|
||||
List<HydrologyPoint> smoothTerrainRoute(List<HydrologyPoint> route) {
|
||||
@@ -220,12 +220,12 @@ final class HydrologyRoutePath {
|
||||
|
||||
boolean traversableTerrainTransition(HydrologyPoint start, HydrologyPoint end) {
|
||||
List<HydrologyPoint> crossing = planner.segments.rasterLine(start, end);
|
||||
HydrologyTerrainSample startTerrain = planner.sampleLandBasisWithoutSlope(start.x(), start.z());
|
||||
HydrologyTerrainSample endTerrain = planner.sampleLandBasisWithoutSlope(end.x(), end.z());
|
||||
if (startTerrain == null || endTerrain == null) {
|
||||
double startHeight = planner.sampleLandHeight(start.x(), start.z());
|
||||
double endHeight = planner.sampleLandHeight(end.x(), end.z());
|
||||
if (Double.isNaN(startHeight) || Double.isNaN(endHeight)) {
|
||||
return false;
|
||||
}
|
||||
int boundaryHeight = Math.min(startTerrain.naturalHeight(), endTerrain.naturalHeight());
|
||||
int boundaryHeight = Math.min((int) startHeight, (int) endHeight);
|
||||
int threshold = planner.settings.hydraulics().waterfallMinimumDrop();
|
||||
for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) {
|
||||
HydrologyPoint point = crossing.get(pointIndex);
|
||||
@@ -244,17 +244,17 @@ final class HydrologyRoutePath {
|
||||
if (crossing.size() < 3) {
|
||||
return false;
|
||||
}
|
||||
HydrologyTerrainSample startTerrain = planner.sampleLandBasisWithoutSlope(start.x(), start.z());
|
||||
HydrologyTerrainSample endTerrain = planner.sampleLandBasisWithoutSlope(end.x(), end.z());
|
||||
if (startTerrain == null || endTerrain == null) {
|
||||
double startHeight = planner.sampleLandHeight(start.x(), start.z());
|
||||
double endHeight = planner.sampleLandHeight(end.x(), end.z());
|
||||
if (Double.isNaN(startHeight) || Double.isNaN(endHeight)) {
|
||||
return true;
|
||||
}
|
||||
int boundaryHeight = Math.min(startTerrain.naturalHeight(), endTerrain.naturalHeight());
|
||||
int boundaryHeight = Math.min((int) startHeight, (int) endHeight);
|
||||
int threshold = planner.settings.hydraulics().waterfallMinimumDrop();
|
||||
for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) {
|
||||
HydrologyPoint point = crossing.get(pointIndex);
|
||||
HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(point.x(), point.z());
|
||||
if (terrain == null || boundaryHeight - terrain.naturalHeight() >= threshold) {
|
||||
double height = planner.sampleLandHeight(point.x(), point.z());
|
||||
if (Double.isNaN(height) || boundaryHeight - (int) height >= threshold) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -5,6 +5,10 @@ public interface HydrologyRoutingTerrainSampler {
|
||||
|
||||
NaturalClassification classifyNatural(int blockX, int blockZ);
|
||||
|
||||
default boolean supportsSharedGridSamples() {
|
||||
return false;
|
||||
}
|
||||
|
||||
enum NaturalClassification {
|
||||
LAND,
|
||||
OCEAN,
|
||||
|
||||
+36
-5
@@ -696,8 +696,15 @@ final class HydrologySourcePlanner {
|
||||
}
|
||||
int x = (int) neighborX;
|
||||
int z = (int) neighborZ;
|
||||
SourceRoutingContext context = sourceContext(x, z, routingContexts);
|
||||
HydrologyGridNode neighbor = context.grid().nodeAtWorld(x, z);
|
||||
HydrologyTileKey owner = HydrologyTileKey.fromBlock(x, z, planner.settings.routing().tileSize());
|
||||
SourceRoutingContext context = routingContexts.get(owner);
|
||||
HydrologyGridNode neighbor = context == null
|
||||
? sampledSourceNode(x, z, routingContexts)
|
||||
: context.grid().nodeAtWorld(x, z);
|
||||
if (neighbor == null) {
|
||||
context = sourceContext(x, z, routingContexts);
|
||||
neighbor = context.grid().nodeAtWorld(x, z);
|
||||
}
|
||||
if (neighbor == null) {
|
||||
throw new IllegalStateException("Source coordinate is absent from its owner routing lattice.");
|
||||
}
|
||||
@@ -711,11 +718,18 @@ final class HydrologySourcePlanner {
|
||||
if (!rawSourceEligible(terrain, sourceSettings, surface)) {
|
||||
continue;
|
||||
}
|
||||
if (!routeViable(neighbor, context, surface)) {
|
||||
SourcePriority neighborPriority = sourcePriority(x, z, terrain, sourceSalt, surface);
|
||||
if (compareSourcePriority(neighborPriority, candidatePriority) <= 0) {
|
||||
continue;
|
||||
}
|
||||
SourcePriority neighborPriority = sourcePriority(x, z, terrain, sourceSalt, surface);
|
||||
if (compareSourcePriority(neighborPriority, candidatePriority) > 0) {
|
||||
if (context == null) {
|
||||
context = sourceContext(x, z, routingContexts);
|
||||
neighbor = context.grid().nodeAtWorld(x, z);
|
||||
if (neighbor == null) {
|
||||
throw new IllegalStateException("Source coordinate is absent from its owner routing lattice.");
|
||||
}
|
||||
}
|
||||
if (routeViable(neighbor, context, surface)) {
|
||||
contenders.add(new SourceContender(neighbor, neighborPriority));
|
||||
}
|
||||
}
|
||||
@@ -729,6 +743,23 @@ final class HydrologySourcePlanner {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private HydrologyGridNode sampledSourceNode(int x, int z,
|
||||
Map<HydrologyTileKey, SourceRoutingContext> routingContexts) {
|
||||
if (!planner.routingSampler.supportsSharedGridSamples()) {
|
||||
return null;
|
||||
}
|
||||
for (SourceRoutingContext context : routingContexts.values()) {
|
||||
if (context.grid().spacing() != planner.settings.routing().sampleSpacing()) {
|
||||
continue;
|
||||
}
|
||||
HydrologyGridNode node = context.grid().nodeAtWorld(x, z);
|
||||
if (node != null) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private SourceRoutingContext sourceContext(
|
||||
int x,
|
||||
int z,
|
||||
|
||||
+5
-14
@@ -705,11 +705,13 @@ final class HydrologySurfaceCoursePlanner {
|
||||
int head,
|
||||
int maximumBankRise
|
||||
) {
|
||||
HydrologyTerrainSample bank = surfaceBankTerrain(point, tangent, signedDistance);
|
||||
if (bank == null) {
|
||||
int bankX = (int) StrictMath.round(point.x() - tangent.z() * signedDistance);
|
||||
int bankZ = (int) StrictMath.round(point.z() + tangent.x() * signedDistance);
|
||||
double bankHeight = planner.sampleLandHeight(bankX, bankZ);
|
||||
if (Double.isNaN(bankHeight)) {
|
||||
return 1.0E12D;
|
||||
}
|
||||
int rise = bank.naturalHeight() - head;
|
||||
int rise = (int) bankHeight - head;
|
||||
int shortfall = HydrologyRouteGeometry.SURFACE_MINIMUM_NATURAL_BANK_RISE - rise;
|
||||
if (shortfall > 0) {
|
||||
return (double) shortfall * shortfall * 4096D;
|
||||
@@ -922,17 +924,6 @@ final class HydrologySurfaceCoursePlanner {
|
||||
return pointX * pointX + pointZ * pointZ;
|
||||
}
|
||||
|
||||
HydrologyTerrainSample surfaceBankTerrain(
|
||||
HydrologyPoint point,
|
||||
RouteDirection tangent,
|
||||
int signedDistance
|
||||
) {
|
||||
HydrologyPoint bankPoint = surfaceBankPoint(point, tangent, signedDistance);
|
||||
int bankX = bankPoint.x();
|
||||
int bankZ = bankPoint.z();
|
||||
return planner.sampleLandBasisWithoutSlope(bankX, bankZ);
|
||||
}
|
||||
|
||||
HydrologyPoint surfaceBankPoint(HydrologyPoint point, RouteDirection tangent, int signedDistance) {
|
||||
return new HydrologyPoint(
|
||||
(int) StrictMath.round(point.x() - tangent.z() * signedDistance),
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -36,7 +37,9 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
private static final int CHUNK_SIZE = 16;
|
||||
private static final int CHUNK_COLUMN_COUNT = CHUNK_SIZE * CHUNK_SIZE;
|
||||
private static final Cache<SharedTileKey, HydrologyTile> SHARED_TILES = Caffeine.newBuilder()
|
||||
.maximumSize(MAXIMUM_SHARED_ENTRIES)
|
||||
.maximumWeight(HydrologyCacheBudget.sharedStudioBytes())
|
||||
.weigher((SharedTileKey key, HydrologyTile tile) -> HydrologyCacheWeights.bounded(
|
||||
HydrologyCacheWeights.tile(tile), HydrologyCacheBudget.sharedStudioBytes(), MAXIMUM_SHARED_ENTRIES))
|
||||
.expireAfterAccess(10, TimeUnit.MINUTES)
|
||||
.build();
|
||||
|
||||
@@ -51,6 +54,7 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
private final Executor prefetchExecutor;
|
||||
private final ConcurrentLinkedQueue<HydrologyTileKey> prefetchQueue;
|
||||
private final Set<HydrologyTileKey> queuedPrefetches;
|
||||
private final LinkedHashSet<ChunkCoordinate> queriedChunks;
|
||||
private final Set<CacheLoadKey<HydrologyTileKey>> demandedPlans;
|
||||
private final AtomicBoolean prefetchActive;
|
||||
private final ConcurrentHashMap<CacheLoadKey<HydrologyTileKey>, CompletableFuture<HydrologyTile>> planning;
|
||||
@@ -117,16 +121,24 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
this.maximumEntries = maximumEntries;
|
||||
this.prefetchQueue = new ConcurrentLinkedQueue<>();
|
||||
this.queuedPrefetches = ConcurrentHashMap.newKeySet();
|
||||
this.queriedChunks = new LinkedHashSet<>();
|
||||
this.demandedPlans = new HashSet<>();
|
||||
this.prefetchActive = new AtomicBoolean();
|
||||
HydrologyCacheBudget budget = HydrologyCacheBudget.runtime();
|
||||
this.tiles = Caffeine.newBuilder()
|
||||
.maximumSize(maximumEntries)
|
||||
.maximumWeight(budget.tileBytes())
|
||||
.weigher((HydrologyTileKey key, HydrologyTile tile) -> HydrologyCacheWeights.bounded(
|
||||
HydrologyCacheWeights.tile(tile), budget.tileBytes(), maximumEntries))
|
||||
.build();
|
||||
this.composedChunks = Caffeine.newBuilder()
|
||||
.maximumSize(MAXIMUM_COMPOSED_CHUNKS)
|
||||
.maximumWeight(budget.columnBytes())
|
||||
.weigher((Long key, ChunkColumns columns) -> HydrologyCacheWeights.bounded(
|
||||
columns.estimatedBytes(), budget.columnBytes(), MAXIMUM_COMPOSED_CHUNKS))
|
||||
.build();
|
||||
this.diagnostics = Caffeine.newBuilder()
|
||||
.maximumSize(maximumEntries)
|
||||
.maximumWeight(budget.diagnosticBytes())
|
||||
.weigher((HydrologyTileKey key, List<HydrologyDiagnosticCandidate> candidates) -> HydrologyCacheWeights.bounded(
|
||||
HydrologyCacheWeights.diagnostics(candidates), budget.diagnosticBytes(), maximumEntries))
|
||||
.build();
|
||||
this.cacheEpoch = new AtomicLong();
|
||||
this.closed = new AtomicBoolean();
|
||||
@@ -198,11 +210,8 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
}
|
||||
try {
|
||||
present = diagnostics.getIfPresent(key);
|
||||
if (present == null) {
|
||||
prepareTerrain();
|
||||
}
|
||||
List<HydrologyDiagnosticCandidate> result = present == null
|
||||
? List.copyOf(planner.diagnosticCandidates(planInline(key, loadKey.epoch()))) : present;
|
||||
? planDiagnostics(key, loadKey.epoch()) : present;
|
||||
synchronized (publicationLock) {
|
||||
if (!closed.get() && cacheEpoch.get() == loadKey.epoch()) {
|
||||
diagnostics.put(key, result);
|
||||
@@ -218,6 +227,14 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private List<HydrologyDiagnosticCandidate> planDiagnostics(HydrologyTileKey key, long epoch) {
|
||||
HydrologyTile tile = planInline(key, epoch);
|
||||
try (HydrologyPlanningAdmission.Permit ignored = HydrologyPlanningAdmission.acquireRoot(closed::get)) {
|
||||
prepareTerrain();
|
||||
return List.copyOf(planner.diagnosticCandidates(tile));
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<List<HydrologyDiagnosticCandidate>> diagnoseAsync(CacheLoadKey<HydrologyTileKey> loadKey) {
|
||||
List<HydrologyDiagnosticCandidate> present = diagnostics.getIfPresent(loadKey.key());
|
||||
if (present != null) {
|
||||
@@ -329,6 +346,13 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
SHARED_TILES.invalidate(sharedKey);
|
||||
}
|
||||
StudioHydrologyTileStore store = persistentStore;
|
||||
try (HydrologyPlanningAdmission.Permit ignored = HydrologyPlanningAdmission.acquireRoot(closed::get)) {
|
||||
return planAdmitted(key, planningEpoch, sharedKey, store);
|
||||
}
|
||||
}
|
||||
|
||||
private HydrologyTile planAdmitted(HydrologyTileKey key, long planningEpoch,
|
||||
SharedTileKey sharedKey, StudioHydrologyTileStore store) {
|
||||
if (store != null && persistentStudioKey(key)) {
|
||||
HydrologyTile persisted = store.load(key).orElse(null);
|
||||
if (persisted != null) {
|
||||
@@ -360,7 +384,7 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
}
|
||||
return planned;
|
||||
} catch (RuntimeException failure) {
|
||||
if (interrupted(failure)) {
|
||||
if (failure instanceof CancellationException || interrupted(failure)) {
|
||||
throw failure;
|
||||
}
|
||||
IrisLogging.error("Hydrology tile " + key.tileX() + "," + key.tileZ()
|
||||
@@ -449,13 +473,24 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
continue;
|
||||
}
|
||||
missing = true;
|
||||
if (prefetchExecutor != null) {
|
||||
prefetch(key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (missing) {
|
||||
requestChunk(chunkX, chunkZ);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
private void requestChunk(int chunkX, int chunkZ) {
|
||||
synchronized (prefetchQueue) {
|
||||
if (prefetchExecutor == null || closed.get() || queriedChunks.size() >= maximumEntries) {
|
||||
return;
|
||||
}
|
||||
queriedChunks.add(new ChunkCoordinate(chunkX, chunkZ));
|
||||
}
|
||||
pumpPrefetch();
|
||||
}
|
||||
|
||||
/** Whether the calling thread must never wait for a cold hydrology plan. */
|
||||
private boolean waitsForbidden() {
|
||||
return waitingForbidden != null && waitingForbidden.getAsBoolean();
|
||||
@@ -473,6 +508,7 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
ArrayList<CompletableFuture<ChunkColumns>> cancelledQueries = new ArrayList<>();
|
||||
synchronized (prefetchQueue) {
|
||||
synchronized (publicationLock) {
|
||||
cacheEpoch.incrementAndGet();
|
||||
@@ -480,13 +516,22 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
tiles.invalidateAll();
|
||||
composedChunks.invalidateAll();
|
||||
diagnostics.invalidateAll();
|
||||
composing.forEach((key, load) -> {
|
||||
if (load.owner() == null && composing.remove(key, load)) {
|
||||
cancelledQueries.add(load.future());
|
||||
}
|
||||
});
|
||||
}
|
||||
prefetchQueue.clear();
|
||||
queuedPrefetches.clear();
|
||||
queriedChunks.clear();
|
||||
demandedPlans.clear();
|
||||
demandBatches = 0;
|
||||
}
|
||||
localChunkColumns.remove();
|
||||
for (CompletableFuture<ChunkColumns> future : cancelledQueries) {
|
||||
future.completeExceptionally(new CancellationException("Hydrology query was invalidated."));
|
||||
}
|
||||
}
|
||||
|
||||
public int size() {
|
||||
@@ -546,7 +591,11 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
collectActiveLoad(active, load);
|
||||
}
|
||||
for (PendingLoad<ChunkColumns> load : composing.values()) {
|
||||
collectActiveLoad(active, load);
|
||||
if (load.owner() == null) {
|
||||
queued.add(load.future());
|
||||
} else {
|
||||
collectActiveLoad(active, load);
|
||||
}
|
||||
}
|
||||
for (PendingLoad<List<HydrologyDiagnosticCandidate>> load : loadingDiagnostics.values()) {
|
||||
collectActiveLoad(active, load);
|
||||
@@ -620,13 +669,29 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
return local.columns();
|
||||
}
|
||||
long packedChunk = CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ));
|
||||
if (composedChunks.getIfPresent(packedChunk) == null
|
||||
&& waitsForbidden()
|
||||
&& requestUnplanned(chunkX, chunkZ)) {
|
||||
// A thread that must never wait (the server thread answering a query) gets a riverless
|
||||
// answer while the tiles it would have waited for are planned on the prefetch executor.
|
||||
// The empty result is never cached, so the next query composes the real columns.
|
||||
return ChunkColumns.empty(chunkX, chunkZ);
|
||||
if (waitsForbidden()) {
|
||||
ChunkColumns present = composedChunks.getIfPresent(packedChunk);
|
||||
if (present != null) {
|
||||
return present;
|
||||
}
|
||||
ArrayList<HydrologyTileKey> keys = relevantKeys(chunkX, chunkZ);
|
||||
ArrayList<HydrologyTile> snapshots = new ArrayList<>(keys.size());
|
||||
for (HydrologyTileKey key : keys) {
|
||||
HydrologyTile tile = tiles.getIfPresent(key);
|
||||
if (tile == null) {
|
||||
requestChunk(chunkX, chunkZ);
|
||||
return ChunkColumns.empty(chunkX, chunkZ);
|
||||
}
|
||||
snapshots.add(tile);
|
||||
}
|
||||
ChunkColumns columns = composeChunkColumns(chunkX, chunkZ, keys, snapshots);
|
||||
synchronized (publicationLock) {
|
||||
if (!closed.get() && cacheEpoch.get() == epoch) {
|
||||
composedChunks.put(packedChunk, columns);
|
||||
}
|
||||
}
|
||||
localChunkColumns.set(new LocalChunkColumns(epoch, columns));
|
||||
return columns;
|
||||
}
|
||||
ChunkColumns columns = loadChunkColumns(new CacheLoadKey<>(epoch, packedChunk), chunkX, chunkZ);
|
||||
localChunkColumns.set(new LocalChunkColumns(epoch, columns));
|
||||
@@ -642,16 +707,29 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
}
|
||||
PendingLoad<ChunkColumns> owned = new PendingLoad<>(Thread.currentThread(), new CompletableFuture<>());
|
||||
PendingLoad<ChunkColumns> existing;
|
||||
boolean claimedQuery = false;
|
||||
synchronized (publicationLock) {
|
||||
requireOpen();
|
||||
existing = composing.putIfAbsent(loadKey, owned);
|
||||
if (existing != null && existing.owner() == null && !existing.future().isDone()) {
|
||||
PendingLoad<ChunkColumns> claimed = new PendingLoad<>(Thread.currentThread(), existing.future());
|
||||
if (composing.replace(loadKey, existing, claimed)) {
|
||||
owned = claimed;
|
||||
existing = null;
|
||||
claimedQuery = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (existing != null) {
|
||||
return awaitLoad(existing);
|
||||
}
|
||||
try {
|
||||
present = composedChunks.getIfPresent(packedChunk);
|
||||
ChunkColumns columns = present == null ? composeChunkColumns(chunkX, chunkZ) : present;
|
||||
ChunkColumns columns = present;
|
||||
if (columns == null) {
|
||||
columns = claimedQuery ? composeChunkColumnsInline(chunkX, chunkZ, epoch)
|
||||
: composeChunkColumns(chunkX, chunkZ);
|
||||
}
|
||||
synchronized (publicationLock) {
|
||||
if (!closed.get() && cacheEpoch.get() == epoch) {
|
||||
composedChunks.put(packedChunk, columns);
|
||||
@@ -689,14 +767,28 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
}
|
||||
|
||||
private ChunkColumns composeChunkColumns(int chunkX, int chunkZ) {
|
||||
ArrayList<HydrologyTileKey> relevantKeys = relevantKeys(chunkX, chunkZ);
|
||||
return composeChunkColumns(chunkX, chunkZ, relevantKeys, tiles(relevantKeys));
|
||||
}
|
||||
|
||||
private ChunkColumns composeChunkColumnsInline(int chunkX, int chunkZ, long epoch) {
|
||||
ArrayList<HydrologyTileKey> keys = relevantKeys(chunkX, chunkZ);
|
||||
ArrayList<HydrologyTile> snapshots = new ArrayList<>(keys.size());
|
||||
for (HydrologyTileKey key : keys) {
|
||||
snapshots.add(planInline(key, epoch));
|
||||
}
|
||||
return composeChunkColumns(chunkX, chunkZ, keys, snapshots);
|
||||
}
|
||||
|
||||
private ChunkColumns composeChunkColumns(int chunkX, int chunkZ,
|
||||
List<HydrologyTileKey> relevantKeys,
|
||||
List<HydrologyTile> relevantTiles) {
|
||||
int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE);
|
||||
int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE);
|
||||
ArrayList<HydrologyTileKey> relevantKeys = relevantKeys(chunkX, chunkZ);
|
||||
int minimumTileX = relevantKeys.getFirst().tileX();
|
||||
int maximumTileX = relevantKeys.getLast().tileX();
|
||||
int minimumTileZ = relevantKeys.getFirst().tileZ();
|
||||
int maximumTileZ = relevantKeys.getLast().tileZ();
|
||||
List<HydrologyTile> relevantTiles = tiles(relevantKeys);
|
||||
prefetchNeighbours(minimumTileX, maximumTileX, minimumTileZ, maximumTileZ);
|
||||
HydrologyColumnSample[] columns = new HydrologyColumnSample[CHUNK_COLUMN_COUNT];
|
||||
for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) {
|
||||
@@ -823,12 +915,22 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
}
|
||||
|
||||
private int speculativeEntryCount() {
|
||||
long entries = tiles.estimatedSize();
|
||||
long budget = HydrologyCacheBudget.runtime().tileBytes();
|
||||
long weight = tiles.policy().eviction().orElseThrow().weightedSize().orElse(0L);
|
||||
long available = speculativeCapacity(entries, budget, weight);
|
||||
return Math.toIntExact(Math.min(
|
||||
maximumEntries,
|
||||
tiles.estimatedSize() + planning.size() + queuedPrefetches.size()
|
||||
Math.max(entries, maximumEntries - available) + planning.size() + queuedPrefetches.size()
|
||||
));
|
||||
}
|
||||
|
||||
private long speculativeCapacity(long entries, long budget, long weight) {
|
||||
long expectedWeight = Math.max(Math.ceilDiv(budget, maximumEntries),
|
||||
Math.ceilDiv(weight, Math.max(1L, entries)));
|
||||
return Math.max(0L, budget - weight) / expectedWeight;
|
||||
}
|
||||
|
||||
private void pumpPrefetch() {
|
||||
synchronized (prefetchQueue) {
|
||||
if (prefetchExecutor == null || closed.get() || demandBatches != 0 || !demandedPlans.isEmpty()
|
||||
@@ -840,23 +942,115 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void scheduleNextPrefetch() {
|
||||
CacheLoadKey<HydrologyTileKey> loadKey;
|
||||
CacheLoadKey<HydrologyTileKey> loadKey = null;
|
||||
ChunkCoordinate chunk;
|
||||
long epoch;
|
||||
synchronized (prefetchQueue) {
|
||||
if (closed.get() || demandBatches != 0 || !demandedPlans.isEmpty()) {
|
||||
prefetchActive.set(false);
|
||||
return;
|
||||
}
|
||||
HydrologyTileKey key = pollPrefetch();
|
||||
if (key == null) {
|
||||
prefetchActive.set(false);
|
||||
return;
|
||||
epoch = cacheEpoch.get();
|
||||
chunk = pollQuery();
|
||||
if (chunk != null) {
|
||||
demandBatches++;
|
||||
} else {
|
||||
HydrologyTileKey key = pollPrefetch();
|
||||
if (key == null) {
|
||||
prefetchActive.set(false);
|
||||
return;
|
||||
}
|
||||
loadKey = new CacheLoadKey<>(epoch, key);
|
||||
}
|
||||
loadKey = new CacheLoadKey<>(cacheEpoch.get(), key);
|
||||
}
|
||||
planAsync(loadKey).whenComplete((tile, failure) -> scheduleNextPrefetch());
|
||||
if (chunk != null) {
|
||||
composeAsync(chunk, epoch).whenComplete((columns, failure) -> {
|
||||
synchronized (prefetchQueue) {
|
||||
if (epoch == cacheEpoch.get()) {
|
||||
queriedChunks.remove(chunk);
|
||||
}
|
||||
}
|
||||
finishDemandBatch(epoch);
|
||||
scheduleNextPrefetch();
|
||||
});
|
||||
} else {
|
||||
planAsync(loadKey).whenComplete((tile, failure) -> scheduleNextPrefetch());
|
||||
}
|
||||
}
|
||||
|
||||
private ChunkCoordinate pollQuery() {
|
||||
while (!queriedChunks.isEmpty()) {
|
||||
ChunkCoordinate chunk = queriedChunks.getFirst();
|
||||
if (composedChunks.getIfPresent(chunk.packed()) == null) {
|
||||
return chunk;
|
||||
}
|
||||
queriedChunks.removeFirst();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private CompletableFuture<ChunkColumns> composeAsync(ChunkCoordinate chunk, long epoch) {
|
||||
CacheLoadKey<Long> loadKey = new CacheLoadKey<>(epoch, chunk.packed());
|
||||
PendingLoad<ChunkColumns> queued = new PendingLoad<>(null, new CompletableFuture<>());
|
||||
synchronized (publicationLock) {
|
||||
if (closed.get() || epoch != cacheEpoch.get()) {
|
||||
return CompletableFuture.failedFuture(new CancellationException("Hydrology query was invalidated."));
|
||||
}
|
||||
PendingLoad<ChunkColumns> existing = composing.putIfAbsent(loadKey, queued);
|
||||
if (existing != null) {
|
||||
return existing.future();
|
||||
}
|
||||
}
|
||||
try {
|
||||
prefetchExecutor.execute(() -> runComposition(loadKey, chunk, queued));
|
||||
} catch (RuntimeException | Error rejected) {
|
||||
if (composing.remove(loadKey, queued)) {
|
||||
queued.future().completeExceptionally(rejected);
|
||||
}
|
||||
}
|
||||
return queued.future();
|
||||
}
|
||||
|
||||
private void runComposition(CacheLoadKey<Long> loadKey, ChunkCoordinate chunk,
|
||||
PendingLoad<ChunkColumns> queued) {
|
||||
PendingLoad<ChunkColumns> active = new PendingLoad<>(Thread.currentThread(), queued.future());
|
||||
boolean started;
|
||||
synchronized (publicationLock) {
|
||||
started = !closed.get() && loadKey.epoch() == cacheEpoch.get() && !queued.future().isDone()
|
||||
&& composing.replace(loadKey, queued, active);
|
||||
}
|
||||
if (!started) {
|
||||
if (composing.remove(loadKey, queued)) {
|
||||
queued.future().completeExceptionally(new CancellationException("Hydrology query was invalidated."));
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ChunkColumns columns = composeChunkColumnsInline(chunk.x(), chunk.z(), loadKey.epoch());
|
||||
synchronized (publicationLock) {
|
||||
if (!closed.get() && cacheEpoch.get() == loadKey.epoch()) {
|
||||
composedChunks.put(loadKey.key(), columns);
|
||||
}
|
||||
}
|
||||
active.future().complete(columns);
|
||||
} catch (Throwable failure) {
|
||||
active.future().completeExceptionally(failure);
|
||||
if (!(failure instanceof CancellationException)) {
|
||||
IrisLogging.reportError(failure);
|
||||
}
|
||||
} finally {
|
||||
composing.remove(loadKey, active);
|
||||
}
|
||||
}
|
||||
|
||||
private HydrologyTileKey pollPrefetch() {
|
||||
tiles.cleanUp();
|
||||
long weight = tiles.policy().eviction().orElseThrow().weightedSize().orElse(0L);
|
||||
if (speculativeCapacity(tiles.estimatedSize(), HydrologyCacheBudget.runtime().tileBytes(), weight) == 0L) {
|
||||
prefetchQueue.clear();
|
||||
queuedPrefetches.clear();
|
||||
return null;
|
||||
}
|
||||
HydrologyTileKey key;
|
||||
while ((key = prefetchQueue.poll()) != null) {
|
||||
if (!queuedPrefetches.remove(key)) {
|
||||
@@ -905,7 +1099,7 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
}
|
||||
return List.of(loaded);
|
||||
}
|
||||
int batch = Math.max(1, maximumEntries / 2);
|
||||
int batch = Math.max(1, Math.min(maximumEntries / 2, HydrologyPlanningAdmission.maximumRoots()));
|
||||
ArrayList<CompletableFuture<HydrologyTile>> futures = new ArrayList<>(Math.min(batch, loaded.length));
|
||||
for (int start = 0; start < loaded.length; start += batch) {
|
||||
int end = Math.min(loaded.length, start + batch);
|
||||
@@ -1129,6 +1323,16 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
}
|
||||
|
||||
private record ChunkColumns(int chunkX, int chunkZ, HydrologyColumnSample[] columns) {
|
||||
private long estimatedBytes() {
|
||||
long bytes = 64L + 8L * columns.length;
|
||||
for (HydrologyColumnSample column : columns) {
|
||||
if (column != null) {
|
||||
bytes += HydrologyCacheWeights.column(column);
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** A chunk with no hydrology at all, the answer for a caller that may not wait for its plan. */
|
||||
private static ChunkColumns empty(int chunkX, int chunkZ) {
|
||||
return new ChunkColumns(chunkX, chunkZ, new HydrologyColumnSample[CHUNK_COLUMN_COUNT]);
|
||||
@@ -1144,6 +1348,12 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
private record LocalChunkColumns(long epoch, ChunkColumns columns) {
|
||||
}
|
||||
|
||||
private record ChunkCoordinate(int x, int z) {
|
||||
private long packed() {
|
||||
return CacheKey.mix(RiverFootprint.pack(x, z));
|
||||
}
|
||||
}
|
||||
|
||||
private record CacheLoadKey<K>(long epoch, K key) {
|
||||
}
|
||||
|
||||
|
||||
+266
-129
@@ -1,15 +1,16 @@
|
||||
package art.arcane.iris.generation.hydrology.cave;
|
||||
|
||||
import it.unimi.dsi.fastutil.ints.IntArrayList;
|
||||
import it.unimi.dsi.fastutil.HashCommon;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
|
||||
import java.util.AbstractCollection;
|
||||
import java.util.AbstractList;
|
||||
import java.util.AbstractMap;
|
||||
import java.util.AbstractSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.NoSuchElementException;
|
||||
@@ -18,6 +19,8 @@ import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BiPredicate;
|
||||
import java.util.function.IntConsumer;
|
||||
import java.util.function.IntPredicate;
|
||||
|
||||
public record HydrologyCavePlan(
|
||||
HydrologyCaveSource source,
|
||||
@@ -56,22 +59,10 @@ public record HydrologyCavePlan(
|
||||
actions = Map.of();
|
||||
baselinePreconditions = Map.of();
|
||||
} else {
|
||||
LinkedHashMap<CavePosition, Byte> packed = new LinkedHashMap<>(requestedPreconditions.size());
|
||||
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : requestedActions.entrySet()) {
|
||||
CaveVoxelPrecondition precondition = requestedPreconditions.get(entry.getKey());
|
||||
packed.put(entry.getKey(), encode(entry.getValue(), precondition));
|
||||
}
|
||||
for (Map.Entry<CavePosition, CaveVoxelPrecondition> entry : requestedPreconditions.entrySet()) {
|
||||
packed.putIfAbsent(entry.getKey(), encode(null, entry.getValue()));
|
||||
}
|
||||
Map<CavePosition, Byte> immutablePacked = Collections.unmodifiableMap(packed);
|
||||
PositionSpatialIndex spatialIndex = new PositionSpatialIndex(immutablePacked);
|
||||
actions = new ActionMap(immutablePacked, requestedActions.size(), spatialIndex);
|
||||
baselinePreconditions = new PreconditionMap(
|
||||
immutablePacked,
|
||||
List.copyOf(requestedPreconditions.keySet()),
|
||||
spatialIndex
|
||||
);
|
||||
PackedPositions packed = new PackedPositions(requestedActions, requestedPreconditions);
|
||||
PositionSpatialIndex spatialIndex = new PositionSpatialIndex(packed);
|
||||
actions = new ActionMap(packed, requestedActions.size(), spatialIndex);
|
||||
baselinePreconditions = new PreconditionMap(packed, packed.preconditionOrder(requestedPreconditions), spatialIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +70,27 @@ public record HydrologyCavePlan(
|
||||
return rejection == HydrologyCaveRejection.NONE;
|
||||
}
|
||||
|
||||
public long estimatedRetainedBytes() {
|
||||
if (!(baselinePreconditions instanceof PreconditionMap preconditions)) {
|
||||
return 192L;
|
||||
}
|
||||
PackedPositions packed = preconditions.packed;
|
||||
PositionSpatialIndex spatialIndex = preconditions.spatialIndex;
|
||||
long spatialCapacity = (long) HashCommon.arraySize(spatialIndex.positionsByChunk.size(), 0.75F) + 1L;
|
||||
long bytes = 512L + arrayBytes((long) packed.x.length * Integer.BYTES) * 3L
|
||||
+ arrayBytes(packed.flags.length) + arrayBytes((long) packed.slots.length * Integer.BYTES)
|
||||
+ arrayBytes((long) preconditions.order.rows.length * Integer.BYTES)
|
||||
+ arrayBytes(spatialCapacity * Long.BYTES) * 2L;
|
||||
for (int[] rows : spatialIndex.positionsByChunk.values()) {
|
||||
bytes += arrayBytes((long) rows.length * Integer.BYTES);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static long arrayBytes(long elementsBytes) {
|
||||
return (elementsBytes + 23L) & ~7L;
|
||||
}
|
||||
|
||||
public void forEachAction(BiConsumer<CavePosition, HydrologyCaveAction> consumer) {
|
||||
actions.forEach(consumer);
|
||||
}
|
||||
@@ -183,12 +195,129 @@ public record HydrologyCavePlan(
|
||||
return preconditions;
|
||||
}
|
||||
|
||||
private static final class PackedPositions {
|
||||
private final int[] x;
|
||||
private final int[] y;
|
||||
private final int[] z;
|
||||
private final byte[] flags;
|
||||
private final int[] slots;
|
||||
private final int mask;
|
||||
private int count;
|
||||
|
||||
private PackedPositions(Map<CavePosition, HydrologyCaveAction> actions,
|
||||
Map<CavePosition, CaveVoxelPrecondition> preconditions) {
|
||||
int size = preconditions.size();
|
||||
x = new int[size];
|
||||
y = new int[size];
|
||||
z = new int[size];
|
||||
flags = new byte[size];
|
||||
long required = Math.max(2L, ((long) size * 4L + 2L) / 3L);
|
||||
if (required > 1 << 30) {
|
||||
throw new IllegalArgumentException("Too many cave positions");
|
||||
}
|
||||
int capacity = Integer.highestOneBit((int) required - 1) << 1;
|
||||
slots = new int[capacity];
|
||||
mask = capacity - 1;
|
||||
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : actions.entrySet()) {
|
||||
append(entry.getKey(), encode(entry.getValue(), preconditions.get(entry.getKey())));
|
||||
}
|
||||
for (Map.Entry<CavePosition, CaveVoxelPrecondition> entry : preconditions.entrySet()) {
|
||||
if (row(entry.getKey()) < 0) {
|
||||
append(entry.getKey(), encode(null, entry.getValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void append(CavePosition position, byte value) {
|
||||
int slot = slot(position);
|
||||
x[count] = position.x();
|
||||
y[count] = position.y();
|
||||
z[count] = position.z();
|
||||
flags[count] = value;
|
||||
slots[slot] = ++count;
|
||||
}
|
||||
|
||||
private int slot(CavePosition position) {
|
||||
int hash = 0x811C9DC5;
|
||||
hash = (hash ^ position.x()) * 0x01000193;
|
||||
hash = (hash ^ position.y()) * 0x01000193;
|
||||
hash = (hash ^ position.z()) * 0x01000193;
|
||||
hash ^= hash >>> 16;
|
||||
hash *= 0x7FEB352D;
|
||||
hash ^= hash >>> 15;
|
||||
hash *= 0x846CA68B;
|
||||
hash ^= hash >>> 16;
|
||||
int slot = hash & mask;
|
||||
while (slots[slot] != 0) {
|
||||
int row = slots[slot] - 1;
|
||||
if (x[row] == position.x() && y[row] == position.y() && z[row] == position.z()) {
|
||||
return slot;
|
||||
}
|
||||
slot = slot + 1 & mask;
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
private int row(CavePosition position) {
|
||||
return slots[slot(position)] - 1;
|
||||
}
|
||||
|
||||
private CavePosition position(int row) {
|
||||
return new CavePosition(x[row], y[row], z[row]);
|
||||
}
|
||||
|
||||
private PositionOrder preconditionOrder(Map<CavePosition, CaveVoxelPrecondition> preconditions) {
|
||||
int[] order = new int[count];
|
||||
int index = 0;
|
||||
for (CavePosition position : preconditions.keySet()) {
|
||||
order[index++] = row(position);
|
||||
}
|
||||
return new PositionOrder(this, order);
|
||||
}
|
||||
|
||||
public Byte get(Object key) {
|
||||
if (!(key instanceof CavePosition position)) {
|
||||
return null;
|
||||
}
|
||||
int row = row(position);
|
||||
return row < 0 ? null : flags[row];
|
||||
}
|
||||
|
||||
public boolean containsKey(Object key) {
|
||||
return key instanceof CavePosition position && row(position) >= 0;
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PositionOrder extends AbstractList<CavePosition> {
|
||||
private final PackedPositions packed;
|
||||
private final int[] rows;
|
||||
|
||||
private PositionOrder(PackedPositions packed, int[] rows) {
|
||||
this.packed = packed;
|
||||
this.rows = rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CavePosition get(int index) {
|
||||
return packed.position(rows[index]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return rows.length;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ActionMap extends AbstractMap<CavePosition, HydrologyCaveAction> {
|
||||
private final Map<CavePosition, Byte> packed;
|
||||
private final PackedPositions packed;
|
||||
private final int size;
|
||||
private final PositionSpatialIndex spatialIndex;
|
||||
|
||||
private ActionMap(Map<CavePosition, Byte> packed, int size, PositionSpatialIndex spatialIndex) {
|
||||
private ActionMap(PackedPositions packed, int size, PositionSpatialIndex spatialIndex) {
|
||||
this.packed = packed;
|
||||
this.size = size;
|
||||
this.spatialIndex = spatialIndex;
|
||||
@@ -229,16 +358,16 @@ public record HydrologyCavePlan(
|
||||
@Override
|
||||
public void forEach(BiConsumer<? super CavePosition, ? super HydrologyCaveAction> consumer) {
|
||||
Objects.requireNonNull(consumer);
|
||||
for (Map.Entry<CavePosition, Byte> entry : packed.entrySet()) {
|
||||
if (hasAction(entry.getValue())) {
|
||||
consumer.accept(entry.getKey(), decodeAction(entry.getValue()));
|
||||
for (int row = 0; row < packed.size(); row++) {
|
||||
if (hasAction(packed.flags[row])) {
|
||||
consumer.accept(packed.position(row), decodeAction(packed.flags[row]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean intersects(int minimumX, int minimumZ, int maximumX, int maximumZ) {
|
||||
return spatialIndex.anyIn(minimumX, minimumZ, maximumX, maximumZ,
|
||||
(CavePosition position) -> hasAction(packed.get(position)));
|
||||
(int row) -> hasAction(packed.flags[row]));
|
||||
}
|
||||
|
||||
private void forEachIn(
|
||||
@@ -248,23 +377,23 @@ public record HydrologyCavePlan(
|
||||
int maximumZ,
|
||||
BiConsumer<CavePosition, HydrologyCaveAction> consumer
|
||||
) {
|
||||
spatialIndex.forEachIn(minimumX, minimumZ, maximumX, maximumZ, (CavePosition position) -> {
|
||||
byte value = packed.get(position);
|
||||
spatialIndex.forEachIn(minimumX, minimumZ, maximumX, maximumZ, (int row) -> {
|
||||
byte value = packed.flags[row];
|
||||
if (hasAction(value)) {
|
||||
consumer.accept(position, decodeAction(value));
|
||||
consumer.accept(packed.position(row), decodeAction(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PreconditionMap extends AbstractMap<CavePosition, CaveVoxelPrecondition> {
|
||||
private final Map<CavePosition, Byte> packed;
|
||||
private final List<CavePosition> order;
|
||||
private final PackedPositions packed;
|
||||
private final PositionOrder order;
|
||||
private final PositionSpatialIndex spatialIndex;
|
||||
|
||||
private PreconditionMap(
|
||||
Map<CavePosition, Byte> packed,
|
||||
List<CavePosition> order,
|
||||
PackedPositions packed,
|
||||
PositionOrder order,
|
||||
PositionSpatialIndex spatialIndex
|
||||
) {
|
||||
this.packed = packed;
|
||||
@@ -306,8 +435,8 @@ public record HydrologyCavePlan(
|
||||
@Override
|
||||
public void forEach(BiConsumer<? super CavePosition, ? super CaveVoxelPrecondition> consumer) {
|
||||
Objects.requireNonNull(consumer);
|
||||
for (CavePosition position : order) {
|
||||
consumer.accept(position, decodePrecondition(packed.get(position)));
|
||||
for (int row : order.rows) {
|
||||
consumer.accept(packed.position(row), decodePrecondition(packed.flags[row]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,26 +448,32 @@ public record HydrologyCavePlan(
|
||||
BiPredicate<CavePosition, CaveVoxelPrecondition> predicate
|
||||
) {
|
||||
return spatialIndex.allIn(minimumX, minimumZ, maximumX, maximumZ,
|
||||
(CavePosition position) -> predicate.test(position, decodePrecondition(packed.get(position))));
|
||||
(int row) -> predicate.test(packed.position(row), decodePrecondition(packed.flags[row])));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PositionSpatialIndex {
|
||||
private static final int CHUNK_SHIFT = 4;
|
||||
|
||||
private final Map<Long, List<CavePosition>> positionsByChunk;
|
||||
private final PackedPositions packed;
|
||||
private final Long2ObjectOpenHashMap<int[]> positionsByChunk;
|
||||
|
||||
private PositionSpatialIndex(Map<CavePosition, Byte> packed) {
|
||||
HashMap<Long, ArrayList<CavePosition>> mutable = new HashMap<>();
|
||||
for (CavePosition position : packed.keySet()) {
|
||||
long chunkKey = packChunk(position.x() >> CHUNK_SHIFT, position.z() >> CHUNK_SHIFT);
|
||||
mutable.computeIfAbsent(chunkKey, ignored -> new ArrayList<>()).add(position);
|
||||
private PositionSpatialIndex(PackedPositions packed) {
|
||||
this.packed = packed;
|
||||
Long2ObjectOpenHashMap<IntArrayList> mutable = new Long2ObjectOpenHashMap<>();
|
||||
for (int row = 0; row < packed.size(); row++) {
|
||||
long chunkKey = packChunk(packed.x[row] >> CHUNK_SHIFT, packed.z[row] >> CHUNK_SHIFT);
|
||||
IntArrayList rows = mutable.get(chunkKey);
|
||||
if (rows == null) {
|
||||
rows = new IntArrayList();
|
||||
mutable.put(chunkKey, rows);
|
||||
}
|
||||
rows.add(row);
|
||||
}
|
||||
HashMap<Long, List<CavePosition>> immutable = HashMap.newHashMap(mutable.size());
|
||||
for (Map.Entry<Long, ArrayList<CavePosition>> entry : mutable.entrySet()) {
|
||||
immutable.put(entry.getKey(), List.copyOf(entry.getValue()));
|
||||
positionsByChunk = new Long2ObjectOpenHashMap<>(mutable.size());
|
||||
for (Long2ObjectMap.Entry<IntArrayList> entry : mutable.long2ObjectEntrySet()) {
|
||||
positionsByChunk.put(entry.getLongKey(), entry.getValue().toIntArray());
|
||||
}
|
||||
positionsByChunk = Map.copyOf(immutable);
|
||||
}
|
||||
|
||||
private boolean anyIn(
|
||||
@@ -346,7 +481,7 @@ public record HydrologyCavePlan(
|
||||
int minimumZ,
|
||||
int maximumX,
|
||||
int maximumZ,
|
||||
java.util.function.Predicate<CavePosition> predicate
|
||||
IntPredicate predicate
|
||||
) {
|
||||
int minimumChunkX = minimumX >> CHUNK_SHIFT;
|
||||
int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT;
|
||||
@@ -354,13 +489,13 @@ public record HydrologyCavePlan(
|
||||
int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT;
|
||||
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
|
||||
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
|
||||
List<CavePosition> positions = positionsByChunk.get(packChunk(chunkX, chunkZ));
|
||||
int[] positions = positionsByChunk.get(packChunk(chunkX, chunkZ));
|
||||
if (positions == null) {
|
||||
continue;
|
||||
}
|
||||
for (CavePosition position : positions) {
|
||||
if (inside(position, minimumX, minimumZ, maximumX, maximumZ)
|
||||
&& predicate.test(position)) {
|
||||
for (int row : positions) {
|
||||
if (inside(row, minimumX, minimumZ, maximumX, maximumZ)
|
||||
&& predicate.test(row)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -374,7 +509,7 @@ public record HydrologyCavePlan(
|
||||
int minimumZ,
|
||||
int maximumX,
|
||||
int maximumZ,
|
||||
java.util.function.Predicate<CavePosition> predicate
|
||||
IntPredicate predicate
|
||||
) {
|
||||
int minimumChunkX = minimumX >> CHUNK_SHIFT;
|
||||
int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT;
|
||||
@@ -382,13 +517,13 @@ public record HydrologyCavePlan(
|
||||
int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT;
|
||||
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
|
||||
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
|
||||
List<CavePosition> positions = positionsByChunk.get(packChunk(chunkX, chunkZ));
|
||||
int[] positions = positionsByChunk.get(packChunk(chunkX, chunkZ));
|
||||
if (positions == null) {
|
||||
continue;
|
||||
}
|
||||
for (CavePosition position : positions) {
|
||||
if (inside(position, minimumX, minimumZ, maximumX, maximumZ)
|
||||
&& !predicate.test(position)) {
|
||||
for (int row : positions) {
|
||||
if (inside(row, minimumX, minimumZ, maximumX, maximumZ)
|
||||
&& !predicate.test(row)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -402,7 +537,7 @@ public record HydrologyCavePlan(
|
||||
int minimumZ,
|
||||
int maximumX,
|
||||
int maximumZ,
|
||||
java.util.function.Consumer<CavePosition> consumer
|
||||
IntConsumer consumer
|
||||
) {
|
||||
int minimumChunkX = minimumX >> CHUNK_SHIFT;
|
||||
int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT;
|
||||
@@ -410,28 +545,28 @@ public record HydrologyCavePlan(
|
||||
int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT;
|
||||
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
|
||||
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
|
||||
List<CavePosition> positions = positionsByChunk.get(packChunk(chunkX, chunkZ));
|
||||
int[] positions = positionsByChunk.get(packChunk(chunkX, chunkZ));
|
||||
if (positions == null) {
|
||||
continue;
|
||||
}
|
||||
for (CavePosition position : positions) {
|
||||
if (inside(position, minimumX, minimumZ, maximumX, maximumZ)) {
|
||||
consumer.accept(position);
|
||||
for (int row : positions) {
|
||||
if (inside(row, minimumX, minimumZ, maximumX, maximumZ)) {
|
||||
consumer.accept(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean inside(
|
||||
CavePosition position,
|
||||
private boolean inside(
|
||||
int row,
|
||||
int minimumX,
|
||||
int minimumZ,
|
||||
int maximumX,
|
||||
int maximumZ
|
||||
) {
|
||||
return position.x() >= minimumX && position.x() < maximumX
|
||||
&& position.z() >= minimumZ && position.z() < maximumZ;
|
||||
return packed.x[row] >= minimumX && packed.x[row] < maximumX
|
||||
&& packed.z[row] >= minimumZ && packed.z[row] < maximumZ;
|
||||
}
|
||||
|
||||
private static long packChunk(int chunkX, int chunkZ) {
|
||||
@@ -440,17 +575,17 @@ public record HydrologyCavePlan(
|
||||
}
|
||||
|
||||
private static final class ActionKeySet extends AbstractSet<CavePosition> {
|
||||
private final Map<CavePosition, Byte> packed;
|
||||
private final PackedPositions packed;
|
||||
private final int size;
|
||||
|
||||
private ActionKeySet(Map<CavePosition, Byte> packed, int size) {
|
||||
private ActionKeySet(PackedPositions packed, int size) {
|
||||
this.packed = packed;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<CavePosition> iterator() {
|
||||
return new ActionKeyIterator(packed.entrySet().iterator());
|
||||
return new ActionKeyIterator(packed);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -466,17 +601,17 @@ public record HydrologyCavePlan(
|
||||
}
|
||||
|
||||
private static final class ActionEntrySet extends AbstractSet<Entry<CavePosition, HydrologyCaveAction>> {
|
||||
private final Map<CavePosition, Byte> packed;
|
||||
private final PackedPositions packed;
|
||||
private final int size;
|
||||
|
||||
private ActionEntrySet(Map<CavePosition, Byte> packed, int size) {
|
||||
private ActionEntrySet(PackedPositions packed, int size) {
|
||||
this.packed = packed;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<Entry<CavePosition, HydrologyCaveAction>> iterator() {
|
||||
return new ActionEntryIterator(packed.entrySet().iterator());
|
||||
return new ActionEntryIterator(packed);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -487,17 +622,17 @@ public record HydrologyCavePlan(
|
||||
|
||||
private static final class PreconditionEntrySet
|
||||
extends AbstractSet<Entry<CavePosition, CaveVoxelPrecondition>> {
|
||||
private final Map<CavePosition, Byte> packed;
|
||||
private final List<CavePosition> order;
|
||||
private final PackedPositions packed;
|
||||
private final PositionOrder order;
|
||||
|
||||
private PreconditionEntrySet(Map<CavePosition, Byte> packed, List<CavePosition> order) {
|
||||
private PreconditionEntrySet(PackedPositions packed, PositionOrder order) {
|
||||
this.packed = packed;
|
||||
this.order = order;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<Entry<CavePosition, CaveVoxelPrecondition>> iterator() {
|
||||
return new PreconditionEntryIterator(packed, order.iterator());
|
||||
return new PreconditionEntryIterator(packed, order.rows);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -507,10 +642,10 @@ public record HydrologyCavePlan(
|
||||
}
|
||||
|
||||
private static final class PreconditionKeySet extends AbstractSet<CavePosition> {
|
||||
private final Map<CavePosition, Byte> packed;
|
||||
private final List<CavePosition> order;
|
||||
private final PackedPositions packed;
|
||||
private final PositionOrder order;
|
||||
|
||||
private PreconditionKeySet(Map<CavePosition, Byte> packed, List<CavePosition> order) {
|
||||
private PreconditionKeySet(PackedPositions packed, PositionOrder order) {
|
||||
this.packed = packed;
|
||||
this.order = order;
|
||||
}
|
||||
@@ -532,17 +667,17 @@ public record HydrologyCavePlan(
|
||||
}
|
||||
|
||||
private static final class ActionValues extends AbstractCollection<HydrologyCaveAction> {
|
||||
private final Map<CavePosition, Byte> packed;
|
||||
private final PackedPositions packed;
|
||||
private final int size;
|
||||
|
||||
private ActionValues(Map<CavePosition, Byte> packed, int size) {
|
||||
private ActionValues(PackedPositions packed, int size) {
|
||||
this.packed = packed;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<HydrologyCaveAction> iterator() {
|
||||
return new ActionValueIterator(packed.entrySet().iterator());
|
||||
return new ActionValueIterator(packed);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -552,17 +687,17 @@ public record HydrologyCavePlan(
|
||||
}
|
||||
|
||||
private static final class PreconditionValues extends AbstractCollection<CaveVoxelPrecondition> {
|
||||
private final Map<CavePosition, Byte> packed;
|
||||
private final List<CavePosition> order;
|
||||
private final PackedPositions packed;
|
||||
private final PositionOrder order;
|
||||
|
||||
private PreconditionValues(Map<CavePosition, Byte> packed, List<CavePosition> order) {
|
||||
private PreconditionValues(PackedPositions packed, PositionOrder order) {
|
||||
this.packed = packed;
|
||||
this.order = order;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<CaveVoxelPrecondition> iterator() {
|
||||
return new PreconditionValueIterator(packed, order.iterator());
|
||||
return new PreconditionValueIterator(packed, order.rows);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -572,121 +707,123 @@ public record HydrologyCavePlan(
|
||||
}
|
||||
|
||||
private abstract static class FilteredActionIterator<T> implements Iterator<T> {
|
||||
private final Iterator<Map.Entry<CavePosition, Byte>> entries;
|
||||
private Map.Entry<CavePosition, Byte> next;
|
||||
protected final PackedPositions packed;
|
||||
private int next;
|
||||
|
||||
private FilteredActionIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
|
||||
this.entries = entries;
|
||||
private FilteredActionIterator(PackedPositions packed) {
|
||||
this.packed = packed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
advance();
|
||||
return next != null;
|
||||
return next < packed.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public T next() {
|
||||
advance();
|
||||
if (next == null) {
|
||||
if (!hasNext()) {
|
||||
throw new NoSuchElementException();
|
||||
}
|
||||
Map.Entry<CavePosition, Byte> selected = next;
|
||||
next = null;
|
||||
return map(selected);
|
||||
return map(next++);
|
||||
}
|
||||
|
||||
protected abstract T map(Map.Entry<CavePosition, Byte> entry);
|
||||
protected abstract T map(int row);
|
||||
|
||||
private void advance() {
|
||||
while (next == null && entries.hasNext()) {
|
||||
Map.Entry<CavePosition, Byte> candidate = entries.next();
|
||||
if (hasAction(candidate.getValue())) {
|
||||
next = candidate;
|
||||
}
|
||||
while (next < packed.size() && !hasAction(packed.flags[next])) {
|
||||
next++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ActionKeyIterator extends FilteredActionIterator<CavePosition> {
|
||||
private ActionKeyIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
|
||||
super(entries);
|
||||
private ActionKeyIterator(PackedPositions packed) {
|
||||
super(packed);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected CavePosition map(Map.Entry<CavePosition, Byte> entry) {
|
||||
return entry.getKey();
|
||||
protected CavePosition map(int row) {
|
||||
return packed.position(row);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ActionEntryIterator
|
||||
extends FilteredActionIterator<Entry<CavePosition, HydrologyCaveAction>> {
|
||||
private ActionEntryIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
|
||||
super(entries);
|
||||
private ActionEntryIterator(PackedPositions packed) {
|
||||
super(packed);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Entry<CavePosition, HydrologyCaveAction> map(Map.Entry<CavePosition, Byte> entry) {
|
||||
return Map.entry(entry.getKey(), decodeAction(entry.getValue()));
|
||||
protected Entry<CavePosition, HydrologyCaveAction> map(int row) {
|
||||
return Map.entry(packed.position(row), decodeAction(packed.flags[row]));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ActionValueIterator extends FilteredActionIterator<HydrologyCaveAction> {
|
||||
private ActionValueIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
|
||||
super(entries);
|
||||
private ActionValueIterator(PackedPositions packed) {
|
||||
super(packed);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HydrologyCaveAction map(Map.Entry<CavePosition, Byte> entry) {
|
||||
return decodeAction(entry.getValue());
|
||||
protected HydrologyCaveAction map(int row) {
|
||||
return decodeAction(packed.flags[row]);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PreconditionEntryIterator
|
||||
implements Iterator<Entry<CavePosition, CaveVoxelPrecondition>> {
|
||||
private final Map<CavePosition, Byte> packed;
|
||||
private final Iterator<CavePosition> positions;
|
||||
private final PackedPositions packed;
|
||||
private final int[] rows;
|
||||
private int index;
|
||||
|
||||
private PreconditionEntryIterator(
|
||||
Map<CavePosition, Byte> packed,
|
||||
Iterator<CavePosition> positions
|
||||
PackedPositions packed,
|
||||
int[] rows
|
||||
) {
|
||||
this.packed = packed;
|
||||
this.positions = positions;
|
||||
this.rows = rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return positions.hasNext();
|
||||
return index < rows.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Entry<CavePosition, CaveVoxelPrecondition> next() {
|
||||
CavePosition position = positions.next();
|
||||
return Map.entry(position, decodePrecondition(packed.get(position)));
|
||||
if (!hasNext()) {
|
||||
throw new NoSuchElementException();
|
||||
}
|
||||
int row = rows[index++];
|
||||
return Map.entry(packed.position(row), decodePrecondition(packed.flags[row]));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PreconditionValueIterator implements Iterator<CaveVoxelPrecondition> {
|
||||
private final Map<CavePosition, Byte> packed;
|
||||
private final Iterator<CavePosition> positions;
|
||||
private final PackedPositions packed;
|
||||
private final int[] rows;
|
||||
private int index;
|
||||
|
||||
private PreconditionValueIterator(
|
||||
Map<CavePosition, Byte> packed,
|
||||
Iterator<CavePosition> positions
|
||||
PackedPositions packed,
|
||||
int[] rows
|
||||
) {
|
||||
this.packed = packed;
|
||||
this.positions = positions;
|
||||
this.rows = rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return positions.hasNext();
|
||||
return index < rows.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CaveVoxelPrecondition next() {
|
||||
return decodePrecondition(packed.get(positions.next()));
|
||||
if (!hasNext()) {
|
||||
throw new NoSuchElementException();
|
||||
}
|
||||
return decodePrecondition(packed.flags[rows[index++]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-1
@@ -45,6 +45,11 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSharedGridSamples() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
|
||||
int minimumX = request.minimumX();
|
||||
@@ -125,6 +130,33 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
return basis(blockX, blockZ).terrain();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double sampleLandHeight(int blockX, int blockZ) {
|
||||
long packed = pack(blockX, blockZ);
|
||||
CacheStripe stripe = stripe(packed);
|
||||
synchronized (stripe) {
|
||||
TerrainBasis cached = stripe.bases.getAndMoveToLast(packed);
|
||||
if (cached != null) {
|
||||
return cached.terrain().ocean() ? Double.NaN : cached.terrain().naturalHeight();
|
||||
}
|
||||
}
|
||||
double naturalHeight = naturalHeight(stripe, packed, blockX, blockZ);
|
||||
if (!Double.isFinite(naturalHeight)) {
|
||||
TerrainBasis basis = loadBasis(blockX, blockZ, stripe, packed, naturalHeight);
|
||||
return basis.terrain().ocean() ? Double.NaN : basis.terrain().naturalHeight();
|
||||
}
|
||||
long roundedHeight = StrictMath.round(naturalHeight);
|
||||
if (roundedHeight >= seaLevel) {
|
||||
return (int) roundedHeight;
|
||||
}
|
||||
NaturalClassification classification = classifyNatural(blockX, blockZ);
|
||||
if (classification == NaturalClassification.UNAVAILABLE) {
|
||||
TerrainBasis basis = loadBasis(blockX, blockZ, stripe, packed, naturalHeight);
|
||||
return basis.terrain().ocean() ? Double.NaN : basis.terrain().naturalHeight();
|
||||
}
|
||||
return classification == NaturalClassification.OCEAN ? Double.NaN : (int) roundedHeight;
|
||||
}
|
||||
|
||||
TerrainBasis basis(int blockX, int blockZ) {
|
||||
long packed = pack(blockX, blockZ);
|
||||
CacheStripe stripe = stripe(packed);
|
||||
@@ -134,7 +166,10 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
double naturalHeight = naturalHeight(stripe, packed, blockX, blockZ);
|
||||
return loadBasis(blockX, blockZ, stripe, packed, naturalHeight(stripe, packed, blockX, blockZ));
|
||||
}
|
||||
|
||||
private TerrainBasis loadBasis(int blockX, int blockZ, CacheStripe stripe, long packed, double naturalHeight) {
|
||||
TerrainBasis sampled = basisProvider.sample(blockX, blockZ, naturalHeight);
|
||||
if (sampled == null) {
|
||||
throw new NullPointerException(
|
||||
|
||||
+34
-10
@@ -79,6 +79,32 @@ public final class ErosionFieldCompiler {
|
||||
HydrologyPlannerSettings.Ponds ponds,
|
||||
SurfaceRasterContext context
|
||||
) {
|
||||
return compile(courseSeed, centerline, channel, valley, terminal, apronLimit, ponds, context,
|
||||
prepare(centerline, channel, valley, terminal));
|
||||
}
|
||||
|
||||
PreparedField prepare(SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley,
|
||||
SurfaceTerminal terminal) {
|
||||
int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations();
|
||||
boolean[] basin = basins(valley, channel, count);
|
||||
double[] bendOffsets = SurfaceBendProfile.offsets(centerline, channel);
|
||||
double[][] blendWidth = blendWidths(centerline, channel, valley, count);
|
||||
int[] radii = new int[count];
|
||||
for (int station = 0; station < count; station++) {
|
||||
double widest = Math.max(blendWidth[station][0], blendWidth[station][1]);
|
||||
// The shore biome band a policy asks for can be wider than the geometric shore and the
|
||||
// eroded valley; the search radius has to reach it so those columns get a shore role.
|
||||
double stationBand = stationShoreBiomeBand(centerline, station, surface.shoreWidth());
|
||||
double stationShore = stationShoreWidth(centerline, station, surface.shoreWidth());
|
||||
radii[station] = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + surface.banks().roughness())
|
||||
+ Math.max(stationShore + widest, stationBand)) + 1;
|
||||
}
|
||||
return new PreparedField(count, basin, bendOffsets, blendWidth, radii);
|
||||
}
|
||||
|
||||
ErosionField compile(long courseSeed, SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley,
|
||||
SurfaceTerminal terminal, int apronLimit, HydrologyPlannerSettings.Ponds ponds,
|
||||
SurfaceRasterContext context, PreparedField prepared) {
|
||||
Objects.requireNonNull(context, "context");
|
||||
SurfaceBounds bounds = context.bounds();
|
||||
SurfaceBounds samplingBounds = bounds == null ? null : bounds.expand(2);
|
||||
@@ -87,24 +113,19 @@ public final class ErosionFieldCompiler {
|
||||
HydrologyPlannerSettings.Erosion erosion = banks.erosion();
|
||||
HydrologyPlannerSettings.Channel channelSettings = banks.channel();
|
||||
HydrologyPlannerSettings.Flow flow = banks.flow();
|
||||
int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations();
|
||||
int count = prepared.count();
|
||||
double roughness = banks.roughness();
|
||||
double shoreWidth = surface.shoreWidth();
|
||||
double shoreRise = erosion.shoreRise();
|
||||
int sink = banks.sink();
|
||||
boolean[] basin = basins(valley, channel, count);
|
||||
double[] bendOffsets = SurfaceBendProfile.offsets(centerline, channel);
|
||||
double[][] blendWidth = blendWidths(centerline, channel, valley, count);
|
||||
boolean[] basin = prepared.basin();
|
||||
double[] bendOffsets = prepared.bendOffsets();
|
||||
double[][] blendWidth = prepared.blendWidth();
|
||||
Long2IntOpenHashMap nearest = new Long2IntOpenHashMap();
|
||||
nearest.defaultReturnValue(-1);
|
||||
Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap();
|
||||
for (int station = 0; station < count; station++) {
|
||||
double widest = Math.max(blendWidth[station][0], blendWidth[station][1]);
|
||||
// The shore biome band a policy asks for can be wider than the geometric shore and the
|
||||
// eroded valley; the search radius has to reach it so those columns get a shore role.
|
||||
double stationBand = stationShoreBiomeBand(centerline, station, shoreWidth);
|
||||
double stationShore = stationShoreWidth(centerline, station, shoreWidth);
|
||||
int radius = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + roughness) + Math.max(stationShore + widest, stationBand)) + 1;
|
||||
int radius = prepared.radii()[station];
|
||||
int stationX = centerline.x()[station];
|
||||
int stationZ = centerline.z()[station];
|
||||
if (samplingBounds != null && !samplingBounds.intersects(stationX, stationZ, radius)) {
|
||||
@@ -743,4 +764,7 @@ public final class ErosionFieldCompiler {
|
||||
double deltaZ = cellZ - centerline.z()[station];
|
||||
return deltaX * centerline.normalX(station) + deltaZ * centerline.normalZ(station);
|
||||
}
|
||||
|
||||
record PreparedField(int count, boolean[] basin, double[] bendOffsets, double[][] blendWidth, int[] radii) {
|
||||
}
|
||||
}
|
||||
|
||||
+63
-14
@@ -51,14 +51,30 @@ public final class SurfaceFootprintCompiler {
|
||||
}
|
||||
|
||||
public SurfaceFootprint compile(RiverCourse course, SurfaceBounds bounds) {
|
||||
return compile(prepare(course), bounds);
|
||||
}
|
||||
|
||||
public PreparedCourse prepare(RiverCourse course) {
|
||||
return new PreparedCourse(this, Objects.requireNonNull(course, "course"));
|
||||
}
|
||||
|
||||
public SurfaceFootprint compile(PreparedCourse prepared, SurfaceBounds bounds) {
|
||||
if (prepared.compiler != this) {
|
||||
throw new IllegalArgumentException("Prepared course belongs to another surface compiler");
|
||||
}
|
||||
RiverCourse course = prepared.course;
|
||||
boolean pool = course.type() == RiverCourseType.SURFACE_POOL;
|
||||
if (course.type() != RiverCourseType.SURFACE && !pool) {
|
||||
return SurfaceFootprint.empty();
|
||||
}
|
||||
HydrologyTerrainSampler receivingSampler = HydrologyOceanReceiver.forCourse(settings, sampler, course);
|
||||
if (prepared.receivingSampler == null) {
|
||||
prepared.receivingSampler = HydrologyOceanReceiver.forCourse(settings, sampler, course);
|
||||
prepared.offsets = stationOffsets(course.segments());
|
||||
}
|
||||
HydrologyTerrainSampler receivingSampler = prepared.receivingSampler;
|
||||
HydrologySurfaceDropRaster drops = HydrologySurfaceDropRaster.compile(settings, receivingSampler, geometry, course,
|
||||
bounds == null ? null : bounds.expand(2));
|
||||
int[] offsets = stationOffsets(course.segments());
|
||||
int[] offsets = prepared.offsets;
|
||||
ArrayList<SurfaceLayerColumn> columns = new ArrayList<>();
|
||||
int uncontained = 0;
|
||||
long excavation = 0L;
|
||||
@@ -74,7 +90,7 @@ public final class SurfaceFootprintCompiler {
|
||||
while (after < course.segments().size() && exposedSegment(course.segments().get(after))) {
|
||||
after++;
|
||||
}
|
||||
SurfaceFootprint run = compileRun(course, first, after, offsets[first], bounds, drops, pool, receivingSampler);
|
||||
SurfaceFootprint run = compileRun(prepared, first, after, offsets[first], bounds, drops, pool, receivingSampler);
|
||||
columns.addAll(run.columns());
|
||||
uncontained += run.uncontainedWetCells();
|
||||
excavation += run.bankExcavation();
|
||||
@@ -106,14 +122,10 @@ public final class SurfaceFootprintCompiler {
|
||||
return new SurfaceFootprint(columns, uncontained, rejection, detail, excavation);
|
||||
}
|
||||
|
||||
private SurfaceFootprint compileRun(RiverCourse course, int first, int after, int stationOffset,
|
||||
SurfaceBounds bounds, HydrologySurfaceDropRaster drops, boolean pool,
|
||||
HydrologyTerrainSampler receivingSampler) {
|
||||
private PreparedRun prepareRun(RiverCourse course, int first, int after, boolean pool,
|
||||
HydrologyTerrainSampler receivingSampler) {
|
||||
List<HydraulicSegment> exposed = course.segments().subList(first, after);
|
||||
Stations stations = stations(exposed);
|
||||
if (stations.count() < 1) {
|
||||
return SurfaceFootprint.empty();
|
||||
}
|
||||
boolean fallingEnd = after < course.segments().size() && course.segments().get(after).fallingFluid();
|
||||
SurfaceTerminal terminal = pool ? SurfaceTerminal.SINKHOLE
|
||||
: fallingEnd ? SurfaceTerminal.TRIBUTARY : terminal(course, exposed);
|
||||
@@ -129,9 +141,29 @@ public final class SurfaceFootprintCompiler {
|
||||
: new HydrologyPlannerSettings.Ponds(first == 0 ? settings.surface().banks().ponds().source() : disabled.source(),
|
||||
fallingEnd ? disabled.terminal() : settings.surface().banks().ponds().terminal());
|
||||
SurfaceRunBoundary boundary = runBoundary(course, first, after, centerline, channel);
|
||||
ErosionField field = new ErosionFieldCompiler(settings.surface(), receivingSampler, settings.seaLevel()).compile(
|
||||
HydrologyHash.mix(course.id(), COURSE_SEED_SALT), centerline, channel, valley, terminal,
|
||||
settings.outlets().maximumOceanApron(), ponds, new SurfaceRasterContext(bounds, drops, boundary));
|
||||
ErosionFieldCompiler erosion = new ErosionFieldCompiler(settings.surface(), receivingSampler, settings.seaLevel());
|
||||
return new PreparedRun(exposed, stations, coastalChannel, centerline, channel, poolBiome, valley,
|
||||
terminal, ponds, boundary, erosion, erosion.prepare(centerline, channel, valley, terminal));
|
||||
}
|
||||
|
||||
private SurfaceFootprint compileRun(PreparedCourse prepared, int first, int after, int stationOffset,
|
||||
SurfaceBounds bounds, HydrologySurfaceDropRaster drops, boolean pool,
|
||||
HydrologyTerrainSampler receivingSampler) {
|
||||
RiverCourse course = prepared.course;
|
||||
PreparedRun run = prepared.runs[first];
|
||||
if (run == null) {
|
||||
run = prepareRun(course, first, after, pool, receivingSampler);
|
||||
prepared.runs[first] = run;
|
||||
}
|
||||
List<HydraulicSegment> exposed = run.exposed();
|
||||
Stations stations = run.stations();
|
||||
SurfaceCenterline centerline = run.centerline();
|
||||
boolean coastalChannel = run.coastalChannel();
|
||||
String poolBiome = run.poolBiome();
|
||||
ErosionField field = run.erosion().compile(
|
||||
HydrologyHash.mix(course.id(), COURSE_SEED_SALT), centerline, run.channel(), run.valley(), run.terminal(),
|
||||
settings.outlets().maximumOceanApron(), run.ponds(), new SurfaceRasterContext(bounds, drops, run.boundary()),
|
||||
run.field());
|
||||
ArrayList<SurfaceColumn> ordered = new ArrayList<>(field.columns().values());
|
||||
ordered.sort(Comparator.comparingInt(SurfaceColumn::station)
|
||||
.thenComparingLong((SurfaceColumn column) -> RiverFootprint.pack(column.x(), column.z())));
|
||||
@@ -367,8 +399,25 @@ public final class SurfaceFootprintCompiler {
|
||||
|
||||
private record Stations(List<HydrologyPoint> points, int[] segmentIndex, int[] head, double[] width,
|
||||
double[] depth, int exposedStations) {
|
||||
private int count() {
|
||||
return points.size();
|
||||
}
|
||||
|
||||
public static final class PreparedCourse {
|
||||
private final SurfaceFootprintCompiler compiler;
|
||||
private final RiverCourse course;
|
||||
private final PreparedRun[] runs;
|
||||
private HydrologyTerrainSampler receivingSampler;
|
||||
private int[] offsets;
|
||||
|
||||
private PreparedCourse(SurfaceFootprintCompiler compiler, RiverCourse course) {
|
||||
this.compiler = compiler;
|
||||
this.course = course;
|
||||
this.runs = new PreparedRun[course.segments().size()];
|
||||
}
|
||||
}
|
||||
|
||||
private record PreparedRun(List<HydraulicSegment> exposed, Stations stations, boolean coastalChannel,
|
||||
SurfaceCenterline centerline, ChannelProfile channel, String poolBiome, ValleyProfile valley,
|
||||
SurfaceTerminal terminal, HydrologyPlannerSettings.Ponds ponds, SurfaceRunBoundary boundary,
|
||||
ErosionFieldCompiler erosion, ErosionFieldCompiler.PreparedField field) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +356,11 @@ public interface EngineMantle extends MatterGenerator {
|
||||
}
|
||||
|
||||
default boolean cleanupCoveredChunk(int x, int z, boolean force) {
|
||||
MantleChunk<Matter> chunk = getMantle().getChunk(x, z).use();
|
||||
return cleanupCoveredChunk(getMantle().getChunk(x, z), force);
|
||||
}
|
||||
|
||||
default boolean cleanupCoveredChunk(MantleChunk<Matter> chunk, boolean force) {
|
||||
chunk.use();
|
||||
try {
|
||||
synchronized (chunk) {
|
||||
if (chunk.isFlagged(MantleFlag.CLEANED)) {
|
||||
|
||||
@@ -599,7 +599,7 @@ public class IrisComplex implements DataProvider {
|
||||
}
|
||||
|
||||
private boolean sampleNaturalOcean(int x, int z) {
|
||||
return resolveNaturalInferredType(bridgeStream, focusBiome, x, z) == InferredType.SEA;
|
||||
return resolveNaturalInferredType(bridgeStream, focusedBiomeAt(x, z), x, z) == InferredType.SEA;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,11 +138,13 @@ public class IrisEngineMantle implements EngineMantle {
|
||||
int candidateX = newRealX + offsetX;
|
||||
for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) {
|
||||
int candidateZ = newRealZ + offsetZ;
|
||||
if (getMantle().hasFlag(candidateX, candidateZ, MantleFlag.CLEANED)
|
||||
if (!isCoverageResident(candidateX, candidateZ, radius)
|
||||
|| getMantle().hasLoadedFlag(candidateX, candidateZ, MantleFlag.CLEANED)
|
||||
|| !isCovered(candidateX, candidateZ, missingChunk)) {
|
||||
continue;
|
||||
}
|
||||
if (cleanupCoveredChunk(candidateX, candidateZ, force)) {
|
||||
if (getMantle().withLoadedChunk(candidateX, candidateZ,
|
||||
chunk -> cleanupCoveredChunk(chunk, force))) {
|
||||
callback.onChunkCleaned(candidateX, candidateZ);
|
||||
}
|
||||
}
|
||||
@@ -246,12 +248,23 @@ public class IrisEngineMantle implements EngineMantle {
|
||||
return Objects.requireNonNull(storageDirectory, "mantle storage directory").toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private boolean isCoverageResident(int x, int z, int radius) {
|
||||
for (int regionX = (x - radius) >> 5; regionX <= (x + radius) >> 5; regionX++) {
|
||||
for (int regionZ = (z - radius) >> 5; regionZ <= (z + radius) >> 5; regionZ++) {
|
||||
if (!getMantle().isChunkLoaded(regionX << 5, regionZ << 5)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isCovered(int x, int z, MissingRealChunk missingChunk) {
|
||||
int radius = Math.max(getRadius(), getRealRadius());
|
||||
if (missingChunk != null && missingChunk.known
|
||||
&& Math.abs((long) missingChunk.x - x) <= radius
|
||||
&& Math.abs((long) missingChunk.z - z) <= radius) {
|
||||
if (!getMantle().hasFlag(missingChunk.x, missingChunk.z, MantleFlag.REAL)) {
|
||||
if (!getMantle().hasLoadedFlag(missingChunk.x, missingChunk.z, MantleFlag.REAL)) {
|
||||
return false;
|
||||
}
|
||||
missingChunk.known = false;
|
||||
@@ -260,7 +273,7 @@ public class IrisEngineMantle implements EngineMantle {
|
||||
for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) {
|
||||
int chunkX = x + offsetX;
|
||||
int chunkZ = z + offsetZ;
|
||||
if (!getMantle().hasFlag(chunkX, chunkZ, MantleFlag.REAL)) {
|
||||
if (!getMantle().hasLoadedFlag(chunkX, chunkZ, MantleFlag.REAL)) {
|
||||
if (missingChunk != null) {
|
||||
missingChunk.x = chunkX;
|
||||
missingChunk.z = chunkZ;
|
||||
@@ -352,14 +365,16 @@ public class IrisEngineMantle implements EngineMantle {
|
||||
|
||||
@Override
|
||||
public <T> void remove(Matter section, int x, int y, int z, Class<T> type) {
|
||||
MatterSlice<T> slice = section.slice(type);
|
||||
slice.set(x, y, z, null);
|
||||
MatterSlice<T> slice = section.getSlice(type);
|
||||
if (slice != null) {
|
||||
slice.set(x, y, z, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T get(Matter section, int x, int y, int z, Class<T> type) {
|
||||
MatterSlice<T> slice = section.slice(type);
|
||||
return slice.get(x, y, z);
|
||||
MatterSlice<T> slice = section.getSlice(type);
|
||||
return slice == null ? null : slice.get(x, y, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -223,6 +223,10 @@ public final class BukkitBlockResolution {
|
||||
return BASE.getNoCompat(bdxf);
|
||||
}
|
||||
|
||||
public static BlockData decodeBlockData(String key) {
|
||||
return BASE.decodeBlockData(key);
|
||||
}
|
||||
|
||||
public static BlockData get(String bdxf) {
|
||||
return BASE.get(bdxf);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ public final class BukkitRegistries implements PlatformRegistries {
|
||||
return INMS.get().generationRegistry();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeBlockState decodeBlockState(String key) {
|
||||
return BukkitBlockState.of(BukkitBlockResolution.decodeBlockData(key));
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeBlockState block(String key) {
|
||||
BlockData data = BukkitBlockResolution.get(key);
|
||||
|
||||
@@ -345,7 +345,7 @@ public class IrisPregenerator {
|
||||
int previousLoaded = loadedBefore;
|
||||
int stagnantRounds = 0;
|
||||
for (int attempt = 0; attempt < 24 && stagnantRounds < 4; attempt++) {
|
||||
mantle.trim(0, 0);
|
||||
mantle.trim(0L);
|
||||
mantle.unloadTectonicPlate(0);
|
||||
int loaded = mantle.getLoadedRegionCount();
|
||||
if (loaded <= 0) {
|
||||
@@ -441,8 +441,7 @@ public class IrisPregenerator {
|
||||
try {
|
||||
Mantle mantle = getMantle();
|
||||
if (mantle != null) {
|
||||
mantle.trim(0, 0);
|
||||
mantle.unloadTectonicPlate(0);
|
||||
mantle.saveOldestIdleTectonicPlate();
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
|
||||
@@ -34,7 +34,7 @@ public final class MantleHeapPressure {
|
||||
private static final long MAXIMUM_HYSTERESIS_MS = 60_000L;
|
||||
private static final ObjectName DIAGNOSTIC_COMMAND = diagnosticCommandName();
|
||||
private static final PanicGcReclaimer PANIC_RECLAIMER = new PanicGcReclaimer(
|
||||
new PanicGcPolicy(10_000L, 60_000L, 60_000L, 15 * 60_000L),
|
||||
new PanicGcPolicy(250L, 5_000L, 10_000L, 60_000L, 60_000L, 15 * 60_000L),
|
||||
new PanicGcActions(
|
||||
System::currentTimeMillis,
|
||||
System::gc,
|
||||
@@ -85,7 +85,8 @@ public final class MantleHeapPressure {
|
||||
}
|
||||
|
||||
public static void requestPanicReclaim() {
|
||||
PANIC_RECLAIMER.request(usedFraction());
|
||||
double fraction = usedFraction();
|
||||
PANIC_RECLAIMER.request(fraction, PRESSURE_GATE.update(fraction));
|
||||
}
|
||||
|
||||
static void invokeHotSpotDiagnosticGc(MBeanServer server) throws Exception {
|
||||
@@ -108,13 +109,17 @@ public final class MantleHeapPressure {
|
||||
}
|
||||
|
||||
record PanicGcPolicy(
|
||||
long explicitGraceMs,
|
||||
long explicitCooldownMs,
|
||||
long diagnosticDelayMs,
|
||||
long diagnosticCooldownMs,
|
||||
long initialFailureBackoffMs,
|
||||
long maximumFailureBackoffMs
|
||||
) {
|
||||
PanicGcPolicy {
|
||||
if (diagnosticDelayMs < 0L
|
||||
if (explicitGraceMs < 0L
|
||||
|| explicitCooldownMs < 0L
|
||||
|| diagnosticDelayMs < 0L
|
||||
|| diagnosticCooldownMs < 0L
|
||||
|| initialFailureBackoffMs < 0L
|
||||
|| maximumFailureBackoffMs < initialFailureBackoffMs) {
|
||||
@@ -138,15 +143,13 @@ public final class MantleHeapPressure {
|
||||
}
|
||||
|
||||
static final class HeapPressureGate {
|
||||
private static final long NOT_BELOW_HIGH_WATER = Long.MIN_VALUE;
|
||||
|
||||
private final double highWater;
|
||||
private final double lowWater;
|
||||
private final long maximumHysteresisMs;
|
||||
private final LongSupplier clock;
|
||||
private final Runnable releaseAction;
|
||||
private boolean engaged;
|
||||
private long belowHighWaterSince;
|
||||
private long episodeStartedAt;
|
||||
|
||||
HeapPressureGate(double highWater, double lowWater, long maximumHysteresisMs, LongSupplier clock, Runnable releaseAction) {
|
||||
if (!Double.isFinite(highWater)
|
||||
@@ -161,7 +164,6 @@ public final class MantleHeapPressure {
|
||||
this.maximumHysteresisMs = maximumHysteresisMs;
|
||||
this.clock = clock;
|
||||
this.releaseAction = releaseAction;
|
||||
this.belowHighWaterSince = NOT_BELOW_HIGH_WATER;
|
||||
}
|
||||
|
||||
synchronized boolean update(double fraction) {
|
||||
@@ -171,7 +173,7 @@ public final class MantleHeapPressure {
|
||||
if (!engaged) {
|
||||
if (fraction >= highWater) {
|
||||
engaged = true;
|
||||
belowHighWaterSince = NOT_BELOW_HIGH_WATER;
|
||||
episodeStartedAt = clock.getAsLong();
|
||||
}
|
||||
return engaged;
|
||||
}
|
||||
@@ -180,16 +182,11 @@ public final class MantleHeapPressure {
|
||||
return false;
|
||||
}
|
||||
if (fraction >= highWater) {
|
||||
belowHighWaterSince = NOT_BELOW_HIGH_WATER;
|
||||
return true;
|
||||
}
|
||||
|
||||
long now = clock.getAsLong();
|
||||
if (belowHighWaterSince == NOT_BELOW_HIGH_WATER) {
|
||||
belowHighWaterSince = now;
|
||||
return true;
|
||||
}
|
||||
if (elapsed(now, belowHighWaterSince) < maximumHysteresisMs) {
|
||||
if (elapsed(now, episodeStartedAt) < maximumHysteresisMs) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -199,7 +196,7 @@ public final class MantleHeapPressure {
|
||||
|
||||
private void release() {
|
||||
engaged = false;
|
||||
belowHighWaterSince = NOT_BELOW_HIGH_WATER;
|
||||
episodeStartedAt = 0L;
|
||||
releaseAction.run();
|
||||
}
|
||||
|
||||
@@ -212,7 +209,10 @@ public final class MantleHeapPressure {
|
||||
private final PanicGcPolicy policy;
|
||||
private final PanicGcActions actions;
|
||||
private boolean panicEpisode;
|
||||
private boolean explicitAttempted;
|
||||
private long episodeStartedAt;
|
||||
private long explicitAttemptAt;
|
||||
private long nextExplicitAllowedAt = Long.MIN_VALUE;
|
||||
private long nextDiagnosticAllowedAt;
|
||||
private long failureBackoffMs;
|
||||
|
||||
@@ -222,17 +222,29 @@ public final class MantleHeapPressure {
|
||||
this.failureBackoffMs = policy.initialFailureBackoffMs();
|
||||
}
|
||||
|
||||
synchronized void request(double fraction) {
|
||||
if (!Double.isFinite(fraction) || fraction <= LOW_WATER) {
|
||||
synchronized void request(double fraction, boolean pressureEngaged) {
|
||||
if (!Double.isFinite(fraction)) {
|
||||
return;
|
||||
}
|
||||
if (fraction <= LOW_WATER) {
|
||||
resetEpisode();
|
||||
return;
|
||||
}
|
||||
|
||||
long now = actions.clock().getAsLong();
|
||||
if (!panicEpisode) {
|
||||
if (fraction < HIGH_WATER) {
|
||||
if (fraction < HIGH_WATER && !pressureEngaged) {
|
||||
return;
|
||||
}
|
||||
beginEpisode(now);
|
||||
panicEpisode = true;
|
||||
episodeStartedAt = now;
|
||||
}
|
||||
if (!explicitAttempted) {
|
||||
if (elapsed(now, episodeStartedAt) < policy.explicitGraceMs()
|
||||
|| now < nextExplicitAllowedAt) {
|
||||
return;
|
||||
}
|
||||
attemptExplicitGc(now);
|
||||
return;
|
||||
}
|
||||
if (elapsed(now, explicitAttemptAt) < policy.diagnosticDelayMs()
|
||||
@@ -256,12 +268,15 @@ public final class MantleHeapPressure {
|
||||
|
||||
synchronized void resetEpisode() {
|
||||
panicEpisode = false;
|
||||
explicitAttempted = false;
|
||||
episodeStartedAt = 0L;
|
||||
explicitAttemptAt = 0L;
|
||||
}
|
||||
|
||||
private void beginEpisode(long now) {
|
||||
panicEpisode = true;
|
||||
private void attemptExplicitGc(long now) {
|
||||
explicitAttempted = true;
|
||||
explicitAttemptAt = now;
|
||||
nextExplicitAllowedAt = deadline(now, policy.explicitCooldownMs());
|
||||
try {
|
||||
actions.explicitGc().run();
|
||||
} catch (RuntimeException failure) {
|
||||
|
||||
@@ -84,7 +84,7 @@ public final class PregenMantleBackpressure {
|
||||
int freed;
|
||||
int resident;
|
||||
try {
|
||||
mantle.trim(0L, 0);
|
||||
mantle.trim(0L);
|
||||
freed = mantle.unloadTectonicPlate(0);
|
||||
resident = mantle.getLoadedRegionCount();
|
||||
} catch (Throwable e) {
|
||||
@@ -120,24 +120,27 @@ public final class PregenMantleBackpressure {
|
||||
}
|
||||
|
||||
public void awaitHeapHeadroom() {
|
||||
awaitHeapHeadroom(MantleHeapPressure::overHighWater, MantleHeapPressure::requestPanicReclaim);
|
||||
}
|
||||
|
||||
void awaitHeapHeadroom(BooleanSupplier heapPressure, Runnable panicReclaim) {
|
||||
Mantle mantle = resolveMantle();
|
||||
long waitStart = M.ms();
|
||||
long lastLog = 0L;
|
||||
while (MantleHeapPressure.overHighWater()) {
|
||||
while (heapPressure.getAsBoolean()) {
|
||||
if (isCancelled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (mantle != null && mantle.getLoadedRegionCount() > maxResidentTectonicPlates) {
|
||||
mantle.trim(0L, 0);
|
||||
mantle.unloadTectonicPlate(0);
|
||||
if (mantle != null) {
|
||||
mantle.saveOldestIdleTectonicPlate();
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
|
||||
MantleHeapPressure.requestPanicReclaim();
|
||||
panicReclaim.run();
|
||||
|
||||
long elapsed = M.ms() - waitStart;
|
||||
if (elapsed >= timeoutMs) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package art.arcane.iris.world.storage.matter;
|
||||
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
import art.arcane.iris.generation.block.B;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.volmlib.util.data.palette.Palette;
|
||||
import art.arcane.volmlib.util.matter.Sliced;
|
||||
import art.arcane.volmlib.util.matter.slices.RawMatter;
|
||||
@@ -33,7 +34,7 @@ public class PlatformBlockMatter extends RawMatter<NativeBlockState> {
|
||||
|
||||
@Override
|
||||
public NativeBlockState readNode(DataInputStream din) throws IOException {
|
||||
NativeBlockState state = B.getState(din.readUTF());
|
||||
NativeBlockState state = IrisPlatforms.get().registries().decodeBlockState(din.readUTF());
|
||||
return state == null ? B.getAirState() : state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package art.arcane.iris.world.storage.matter;
|
||||
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.generation.block.B;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.volmlib.util.data.palette.Palette;
|
||||
import art.arcane.volmlib.util.function.Consumer4;
|
||||
import art.arcane.volmlib.util.function.Consumer4IO;
|
||||
@@ -114,7 +114,7 @@ public final class PreObjectMatter extends MappedHunk<PreObjectMatterCell>
|
||||
public PreObjectMatterCell readNode(DataInputStream input) throws IOException {
|
||||
int flags = input.readUnsignedByte();
|
||||
validateFlags(flags);
|
||||
NativeBlockState block = (flags & BLOCK_PRESENT) == 0 ? null : B.getState(input.readUTF());
|
||||
NativeBlockState block = (flags & BLOCK_PRESENT) == 0 ? null : IrisPlatforms.get().registries().decodeBlockState(input.readUTF());
|
||||
String string = (flags & STRING_PRESENT) == 0 ? null : input.readUTF();
|
||||
MatterCavern cavern = (flags & CAVERN_PRESENT) == 0
|
||||
? null
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HydrologyCacheBudgetTest {
|
||||
private static final long MEBIBYTE = 1024L * 1024L;
|
||||
|
||||
@Test
|
||||
public void retainedCacheAllowancesShareOneHeapScaledLimit() {
|
||||
for (long heap : new long[]{32L * MEBIBYTE, 1024L * MEBIBYTE, 2048L * MEBIBYTE, 4096L * MEBIBYTE, Long.MAX_VALUE}) {
|
||||
for (boolean regionalEnabled : new boolean[]{false, true}) {
|
||||
HydrologyCacheBudget budget = HydrologyCacheBudget.forHeap(heap, regionalEnabled);
|
||||
long total = budget.tileBytes() + budget.columnBytes() + budget.diagnosticBytes()
|
||||
+ budget.routingBytes() + budget.ownerBytes() + budget.edgeBytes() + budget.regionalDraftBytes()
|
||||
+ budget.regionalCoarseBytes() + budget.regionalTerrainBytes() + budget.regionalReachBytes();
|
||||
assertTrue(total <= Math.clamp(heap / 8L, 8L * MEBIBYTE, 256L * MEBIBYTE));
|
||||
assertTrue(total >= 8L * MEBIBYTE);
|
||||
assertEquals(budget.tileBytes() / 6L, budget.columnBytes());
|
||||
}
|
||||
}
|
||||
HydrologyCacheBudget twoGigabytes = HydrologyCacheBudget.forHeap(2048L * MEBIBYTE, true);
|
||||
assertEquals(96L * MEBIBYTE, twoGigabytes.tileBytes());
|
||||
assertEquals(32L * MEBIBYTE, twoGigabytes.ownerBytes());
|
||||
assertEquals(32L * MEBIBYTE, twoGigabytes.regionalTerrainBytes());
|
||||
HydrologyCacheBudget localOnly = HydrologyCacheBudget.forHeap(2048L * MEBIBYTE, false);
|
||||
assertEquals(twoGigabytes.tileBytes(), localOnly.tileBytes());
|
||||
assertEquals(96L * MEBIBYTE, localOnly.ownerBytes());
|
||||
assertEquals(24L * MEBIBYTE, localOnly.routingBytes());
|
||||
assertEquals(8L * MEBIBYTE, localOnly.edgeBytes());
|
||||
assertEquals(0L, localOnly.regionalDraftBytes() + localOnly.regionalCoarseBytes()
|
||||
+ localOnly.regionalTerrainBytes() + localOnly.regionalReachBytes());
|
||||
assertThrows(IllegalArgumentException.class, () -> HydrologyCacheBudget.forHeap(0L, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void disabledRegionalCachesLeaveTheirBudgetForLocalPlanning() throws Exception {
|
||||
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyPlannerSettings.defaults(),
|
||||
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
|
||||
HydrologyCacheBudget budget = HydrologyCacheBudget.runtime(false);
|
||||
assertEquals(budget.ownerBytes(), planner.resolvedOwners.policy().eviction().orElseThrow().getMaximum());
|
||||
assertEquals(budget.routingBytes(), planner.routingContexts.policy().eviction().orElseThrow().getMaximum());
|
||||
assertEquals(budget.edgeBytes(), planner.refinedEdgeCache.policy().eviction().orElseThrow().getMaximum());
|
||||
Field field = HydrologyRegionalPlanner.class.getDeclaredField("drafts");
|
||||
field.setAccessible(true);
|
||||
Cache<HydrologyTileKey, HydrologyRegionalNetwork> drafts =
|
||||
(Cache<HydrologyTileKey, HydrologyRegionalNetwork>) field.get(planner.regional);
|
||||
assertEquals(0L, drafts.policy().eviction().orElseThrow().getMaximum());
|
||||
drafts.put(new HydrologyTileKey(0, 0), HydrologyRegionalNetwork.EMPTY);
|
||||
drafts.cleanUp();
|
||||
assertEquals(0L, drafts.estimatedSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void weightsKeepEntryAndStationLimitsAndSaturateLargeValues() {
|
||||
assertEquals(64, HydrologyCacheWeights.bounded(1L, 1024L, 16));
|
||||
assertEquals(256, HydrologyCacheWeights.bounded(256L, 1024L, 16));
|
||||
assertEquals(512, HydrologyCacheWeights.bounded(1L, 1024L, 16, 50, 100));
|
||||
assertEquals(Integer.MAX_VALUE, HydrologyCacheWeights.bounded(Long.MAX_VALUE, 1024L, 16));
|
||||
Cache<Integer, Integer> cache = Caffeine.newBuilder().maximumWeight(1024L)
|
||||
.weigher((Integer key, Integer bytes) -> HydrologyCacheWeights.bounded(bytes, 1024L, 16)).build();
|
||||
for (int key = 0; key < 64; key++) {
|
||||
cache.put(key, 1);
|
||||
}
|
||||
cache.cleanUp();
|
||||
assertTrue(cache.estimatedSize() <= 16L);
|
||||
cache.put(64, 2048);
|
||||
cache.cleanUp();
|
||||
assertNull(cache.getIfPresent(64));
|
||||
assertTrue(cache.policy().eviction().orElseThrow().weightedSize().orElseThrow() <= 1024L);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void largeRoutingContextsEvictByBytesBeforeTheEntryLimit() {
|
||||
HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(70, 0D, "land");
|
||||
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), (x, z) -> terrain);
|
||||
long budget = HydrologyCacheBudget.runtime().routingBytes();
|
||||
assertTrue(planner.routingContexts.policy().eviction().orElseThrow().isWeighted());
|
||||
assertEquals(budget, planner.routingContexts.policy().eviction().orElseThrow().getMaximum());
|
||||
HydrologyGridNode node = new HydrologyGridNode(0, 0, 0, 0, 0, 1L, terrain);
|
||||
int nodeCount = (int) (budget / 640L);
|
||||
SourceRoutingContext context = new SourceRoutingContext(
|
||||
new HydrologySampledGrid(0, 0, 0, 0, 16, 1, 16, Collections.nCopies(nodeCount, node)),
|
||||
null, null, List.of());
|
||||
for (int index = 0; index < 8; index++) {
|
||||
planner.routingContexts.put(new HydrologyTileKey(index, 0), context);
|
||||
}
|
||||
planner.routingContexts.cleanUp();
|
||||
assertTrue(planner.routingContexts.estimatedSize() < 8L);
|
||||
assertTrue(planner.routingContexts.policy().eviction().orElseThrow().weightedSize().orElseThrow() <= budget);
|
||||
assertNotNull(planner.routingContexts.asMap().values().iterator().next());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void regionalTreeWeightCountsSharedDownstreamLabelsOnce() {
|
||||
HydrologyRegionalGraph.Label downstream = new HydrologyRegionalGraph.Label(
|
||||
new HydrologyRegionalGraph.LabelState(0, 0, 63, 0D, 0D, 0), null);
|
||||
HydrologyRegionalGraph.Label first = new HydrologyRegionalGraph.Label(
|
||||
new HydrologyRegionalGraph.LabelState(1, 1, 63, 1D, 1D, 0), downstream);
|
||||
HydrologyRegionalGraph.Label second = new HydrologyRegionalGraph.Label(
|
||||
new HydrologyRegionalGraph.LabelState(3, 2, 63, 1D, 1D, 0), downstream);
|
||||
HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(
|
||||
new HydrologyRegionalGraph.Label[]{first, second, null}, new int[4], List.of());
|
||||
assertEquals(3, tree.retainedLabelCount());
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -143,14 +143,15 @@ public class HydrologyDiagnosticCacheTest {
|
||||
}
|
||||
return fresh;
|
||||
});
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
ExecutorService caller = Executors.newFixedThreadPool(2);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
|
||||
try {
|
||||
Future<List<HydrologyDiagnosticCandidate>> first = caller.submit(() -> cache.diagnosticCandidates(key));
|
||||
assertTrue(started.await(5L, TimeUnit.SECONDS));
|
||||
cache.clear();
|
||||
assertEquals(fresh, cache.diagnosticCandidates(key));
|
||||
Future<List<HydrologyDiagnosticCandidate>> second = caller.submit(() -> cache.diagnosticCandidates(key));
|
||||
release.countDown();
|
||||
assertEquals(fresh, second.get(5L, TimeUnit.SECONDS));
|
||||
assertEquals(old, first.get(5L, TimeUnit.SECONDS));
|
||||
assertEquals(fresh, cache.diagnosticCandidates(key));
|
||||
assertEquals(2, attempts.get());
|
||||
|
||||
+82
-10
@@ -35,6 +35,8 @@ public class HydrologyFallbackParallelismTest {
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void publicationRejectionConsumesTheNextCompletedOutletWithoutRebuildingIt() throws Exception {
|
||||
int parallelism = HydrologyPlanningAdmission.effectiveParallelism(4);
|
||||
int batchSize = parallelism <= 2 ? 1 : Math.min(parallelism, HydrologyPlanningAdmission.maximumTrials());
|
||||
AtomicIntegerArray builds = new AtomicIntegerArray(4);
|
||||
Fixture fixture = fixture(outlet -> {
|
||||
builds.incrementAndGet(outlet);
|
||||
@@ -53,23 +55,89 @@ public class HydrologyFallbackParallelismTest {
|
||||
if (result.courses().isEmpty()) {
|
||||
return new CrossTilePublicationAdmission(result, List.of(), false);
|
||||
}
|
||||
for (int outlet = 0; outlet < 4; outlet++) {
|
||||
assertEquals("Every trial provider must finish before publication", 1, builds.get(outlet));
|
||||
}
|
||||
long course = result.courses().getFirst().id();
|
||||
int completed = Math.min(4, Math.ceilDiv((int) (course - 199L), batchSize) * batchSize);
|
||||
for (int outlet = 0; outlet < 4; outlet++) {
|
||||
assertEquals("Every admitted trial provider must finish before publication", outlet < completed ? 1 : 0, builds.get(outlet));
|
||||
}
|
||||
submitted.add(course);
|
||||
return course == 200L
|
||||
? new CrossTilePublicationAdmission(EMPTY, List.of(diagnostic(900L)), true)
|
||||
: new CrossTilePublicationAdmission(result, List.of(), false);
|
||||
}
|
||||
};
|
||||
HydrologyOwnerDraft draft = run(fixture, admission);
|
||||
HydrologyOwnerDraft draft = run(fixture, admission, 4);
|
||||
|
||||
assertEquals(List.of(200L, 201L), submitted);
|
||||
assertEquals(List.of(201L), draft.result().courses().stream().map(RiverCourse::id).toList());
|
||||
assertEquals(List.of(200L, 900L, 201L), draft.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList());
|
||||
for (int outlet = 0; outlet < 4; outlet++) {
|
||||
assertEquals(1, builds.get(outlet));
|
||||
int completed = Math.min(4, Math.ceilDiv(2, batchSize) * batchSize);
|
||||
assertEquals(outlet < completed ? 1 : 0, builds.get(outlet));
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void limitedWorkersStopAfterTheFirstAcceptedOutlet() throws Exception {
|
||||
int[] poolSizes = Runtime.getRuntime().availableProcessors() <= 2 ? new int[]{1, 2, 16} : new int[]{1, 2};
|
||||
for (int parallelism : poolSizes) {
|
||||
AtomicIntegerArray builds = new AtomicIntegerArray(4);
|
||||
Fixture fixture = fixture(outlet -> builds.incrementAndGet(outlet));
|
||||
|
||||
HydrologyOwnerDraft draft = run(fixture, null, parallelism);
|
||||
|
||||
assertEquals(List.of(200L), draft.result().courses().stream().map(RiverCourse::id).toList());
|
||||
assertEquals(List.of(200L), draft.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList());
|
||||
assertEquals(1, builds.get(0));
|
||||
for (int outlet = 1; outlet < 4; outlet++) {
|
||||
assertEquals("Unused outlets must not be compiled", 0, builds.get(outlet));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void rejectedOutletsAdvanceToTheNextWorkerBoundedBatch() throws Exception {
|
||||
int batchSize = 1;
|
||||
AtomicIntegerArray builds = new AtomicIntegerArray(4);
|
||||
Fixture fixture = fixture(outlet -> {
|
||||
builds.incrementAndGet(outlet);
|
||||
if (outlet == 3) {
|
||||
throw new IllegalStateException("Unused fallback provider failed");
|
||||
}
|
||||
});
|
||||
ArrayList<Long> submitted = new ArrayList<>();
|
||||
CrossTileDraftAdmission admission = new CrossTileDraftAdmission() {
|
||||
@Override
|
||||
public void prepare() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public CrossTilePublicationAdmission admit(HydrologyCaveCourseFilter.Result result) {
|
||||
if (result.courses().isEmpty()) {
|
||||
return new CrossTilePublicationAdmission(result, List.of(), false);
|
||||
}
|
||||
long course = result.courses().getFirst().id();
|
||||
submitted.add(course);
|
||||
for (int outlet = 0; outlet < 4; outlet++) {
|
||||
int completed = Math.min(4, Math.ceilDiv((int) (course - 199L), batchSize) * batchSize);
|
||||
int expected = outlet < completed ? 1 : 0;
|
||||
assertEquals("Only the demanded batch may be compiled", expected, builds.get(outlet));
|
||||
}
|
||||
return course < 202L
|
||||
? new CrossTilePublicationAdmission(EMPTY, List.of(diagnostic(course + 700L)), true)
|
||||
: new CrossTilePublicationAdmission(result, List.of(), false);
|
||||
}
|
||||
};
|
||||
|
||||
HydrologyOwnerDraft draft = run(fixture, admission, 2);
|
||||
|
||||
assertEquals(List.of(200L, 201L, 202L), submitted);
|
||||
assertEquals(List.of(202L), draft.result().courses().stream().map(RiverCourse::id).toList());
|
||||
assertEquals(List.of(200L, 900L, 201L, 901L, 202L),
|
||||
draft.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList());
|
||||
for (int outlet = 0; outlet < 4; outlet++) {
|
||||
int completed = Math.min(4, Math.ceilDiv(3, batchSize) * batchSize);
|
||||
assertEquals("Rejected trials must not be rebuilt", outlet < completed ? 1 : 0, builds.get(outlet));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,8 +163,11 @@ public class HydrologyFallbackParallelismTest {
|
||||
try {
|
||||
Future<HydrologyOwnerDraft> pending = pool.submit(() -> fixture.resolver().compileOwnerDraft(KEY, null, false));
|
||||
assertTrue(failed.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(blocked.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(pending.isDone());
|
||||
if (HydrologyPlanningAdmission.maximumTrials() > 1
|
||||
&& HydrologyPlanningAdmission.effectiveParallelism(4) > 2) {
|
||||
assertTrue(blocked.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(pending.isDone());
|
||||
}
|
||||
release.countDown();
|
||||
ExecutionException failure = assertThrows(ExecutionException.class, () -> pending.get(5, TimeUnit.SECONDS));
|
||||
Throwable cause = failure.getCause();
|
||||
@@ -104,7 +175,8 @@ public class HydrologyFallbackParallelismTest {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
assertSame(expected, cause);
|
||||
assertTrue(completed.get());
|
||||
assertEquals(HydrologyPlanningAdmission.maximumTrials() > 1
|
||||
&& HydrologyPlanningAdmission.effectiveParallelism(4) > 2, completed.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
pool.shutdownNow();
|
||||
@@ -112,8 +184,8 @@ public class HydrologyFallbackParallelismTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static HydrologyOwnerDraft run(Fixture fixture, CrossTileDraftAdmission admission) throws Exception {
|
||||
ForkJoinPool pool = new ForkJoinPool(4);
|
||||
private static HydrologyOwnerDraft run(Fixture fixture, CrossTileDraftAdmission admission, int parallelism) throws Exception {
|
||||
ForkJoinPool pool = new ForkJoinPool(parallelism);
|
||||
try {
|
||||
return pool.submit(() -> fixture.resolver().compileOwnerDraft(KEY, admission, false)).get(10, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class HydrologyLandHeightCacheTest {
|
||||
@Test
|
||||
public void evictionAndFailuresRetainExactScalarValues() {
|
||||
HydrologyLandHeightCache cache = new HydrologyLandHeightCache(1);
|
||||
HydrologyNaturalTerrainSampler sampler = mock(HydrologyNaturalTerrainSampler.class);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
when(sampler.sampleLandHeight(anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
calls.incrementAndGet();
|
||||
int x = invocation.getArgument(0);
|
||||
return x == Integer.MIN_VALUE ? Double.NaN : -0D;
|
||||
});
|
||||
assertEquals(Double.NaN, cache.sample(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler), 0D);
|
||||
assertEquals(Double.NaN, cache.sample(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler), 0D);
|
||||
assertEquals(1, calls.get());
|
||||
assertEquals(Double.doubleToRawLongBits(-0D), Double.doubleToRawLongBits(cache.sample(0, 0, sampler)));
|
||||
assertEquals(Double.doubleToRawLongBits(-0D), Double.doubleToRawLongBits(cache.sample(0, 0, sampler)));
|
||||
assertEquals(2, calls.get());
|
||||
assertEquals(Double.NaN, cache.sample(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler), 0D);
|
||||
assertEquals(3, calls.get());
|
||||
when(sampler.sampleLandHeight(7, 11)).thenThrow(new IllegalStateException("retry")).thenReturn(83D);
|
||||
assertThrows(IllegalStateException.class, () -> cache.sample(7, 11, sampler));
|
||||
assertEquals(83D, cache.sample(7, 11, sampler), 0D);
|
||||
assertEquals(83D, cache.sample(7, 11, sampler), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parallelAlternativesShareOneMissingSample() throws Exception {
|
||||
HydrologyLandHeightCache cache = new HydrologyLandHeightCache(64);
|
||||
HydrologyNaturalTerrainSampler sampler = mock(HydrologyNaturalTerrainSampler.class);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
CountDownLatch ready = new CountDownLatch(4);
|
||||
when(sampler.sampleLandHeight(-19, 33)).thenAnswer(invocation -> {
|
||||
calls.incrementAndGet();
|
||||
started.countDown();
|
||||
assertTrue(release.await(5, TimeUnit.SECONDS));
|
||||
return Double.NaN;
|
||||
});
|
||||
ExecutorService executor = Executors.newFixedThreadPool(4);
|
||||
try {
|
||||
List<Future<Double>> results = new ArrayList<>();
|
||||
for (int index = 0; index < 4; index++) {
|
||||
results.add(executor.submit(() -> {
|
||||
ready.countDown();
|
||||
return cache.sample(-19, 33, sampler);
|
||||
}));
|
||||
}
|
||||
assertTrue(ready.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
assertEquals(1, calls.get());
|
||||
release.countDown();
|
||||
for (Future<Double> result : results) {
|
||||
assertEquals(Double.NaN, result.get(5, TimeUnit.SECONDS), 0D);
|
||||
}
|
||||
assertEquals(1, calls.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
executor.shutdownNow();
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
@@ -78,6 +78,90 @@ public class HydrologyPlannerRoutingSamplerTest {
|
||||
assertThrows(IllegalStateException.class, () -> planner.plan(new HydrologyTileKey(0, 0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landHeightMemoizationRetainsMissingSamplesOnlyWithinThePlanningScope() {
|
||||
AtomicInteger heightCalls = new AtomicInteger();
|
||||
AtomicInteger failedCalls = new AtomicInteger();
|
||||
HydrologyNaturalTerrainSampler natural = new HydrologyNaturalTerrainSampler() {
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
|
||||
throw new AssertionError("Bank heights do not need a routing grid");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NaturalClassification classifyNatural(int x, int z) {
|
||||
throw new AssertionError("Bank height provider already classifies land");
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample sampleBasis(int x, int z) {
|
||||
return switch (x) {
|
||||
case 10 -> HydrologyTerrainSample.openLand(87, 0D, "land");
|
||||
case 11 -> HydrologyTerrainSample.ocean(60, "ocean");
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public double sampleLandHeight(int x, int z) {
|
||||
heightCalls.incrementAndGet();
|
||||
if (x == 4 && failedCalls.incrementAndGet() == 1) {
|
||||
throw new IllegalStateException("Transient height failure");
|
||||
}
|
||||
return switch (x) {
|
||||
case 0 -> Double.NaN;
|
||||
case 1 -> 0D;
|
||||
case 2 -> Integer.MIN_VALUE;
|
||||
default -> 83D;
|
||||
};
|
||||
}
|
||||
};
|
||||
HydrologyTerrainSampler detail = (x, z) -> HydrologyTerrainSample.openLand(90, 0D, "land");
|
||||
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
|
||||
HydrologyPlanner planner = new HydrologyPlanner(23L, settings, detail, natural,
|
||||
HydrologyGeometrySampler.deterministic(detail), -4096,
|
||||
footprint -> new HydrologyTerrainCaveVoxelView(detail, settings.seaLevel(), -4096, 4096));
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
|
||||
try {
|
||||
for (int pass = 0; pass < 3; pass++) {
|
||||
assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D);
|
||||
assertEquals(0D, planner.sampleLandHeight(1, -17), 0D);
|
||||
assertEquals(Integer.MIN_VALUE, planner.sampleLandHeight(2, -17), 0D);
|
||||
assertEquals(83D, planner.sampleLandHeight(3, -17), 0D);
|
||||
}
|
||||
assertEquals(4, heightCalls.get());
|
||||
planner.sampleBasis(10, -17);
|
||||
planner.sampleBasisWithoutSlope(11, -17);
|
||||
planner.sampleBasisWithoutSlope(12, -17);
|
||||
assertEquals(87D, planner.sampleLandHeight(10, -17), 0D);
|
||||
assertEquals(Double.NaN, planner.sampleLandHeight(11, -17), 0D);
|
||||
assertEquals(Double.NaN, planner.sampleLandHeight(12, -17), 0D);
|
||||
assertEquals(4, heightCalls.get());
|
||||
assertThrows(IllegalStateException.class, () -> planner.sampleLandHeight(4, -17));
|
||||
assertEquals(83D, planner.sampleLandHeight(4, -17), 0D);
|
||||
assertEquals(83D, planner.sampleLandHeight(4, -17), 0D);
|
||||
assertEquals(6, heightCalls.get());
|
||||
HydrologyLandHeightCache shared = planner.planningSamples.get().fallbackLandHeights();
|
||||
for (int trial = 0; trial < 3; trial++) {
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(shared));
|
||||
assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D);
|
||||
assertEquals(0D, planner.sampleLandHeight(1, -17), 0D);
|
||||
assertEquals(Integer.MIN_VALUE, planner.sampleLandHeight(2, -17), 0D);
|
||||
assertEquals(83D, planner.sampleLandHeight(3, -17), 0D);
|
||||
assertEquals(83D, planner.sampleLandHeight(4, -17), 0D);
|
||||
}
|
||||
assertEquals(6, heightCalls.get());
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
|
||||
assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D);
|
||||
assertEquals(7, heightCalls.get());
|
||||
} finally {
|
||||
planner.planningSamples.remove();
|
||||
}
|
||||
assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D);
|
||||
assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D);
|
||||
assertEquals(9, heightCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void oceanClassificationBypassesNaturalBasisSampling() {
|
||||
AtomicInteger basisCalls = new AtomicInteger();
|
||||
|
||||
@@ -95,6 +95,26 @@ public class HydrologyPlannerTest {
|
||||
return draft.invoke(resolution);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void independentOwnersReleaseCompilerWithoutChangingPublication() {
|
||||
HydrologyPlanner rootPlanner = new HydrologyPlanner(77L, EARLY_OWNER_SETTINGS, EARLY_OWNER_TERRAIN);
|
||||
CrossTileResolution root = rootPlanner.resolveCrossTileOwner(EARLY_OWNER_TILE);
|
||||
HydrologyFootprintCompiler rootCompiler = root.draft().footprintCompiler();
|
||||
assertNotNull(rootCompiler);
|
||||
assertFalse(root.draft().result().courses().isEmpty());
|
||||
|
||||
HydrologyPlanner dependencyPlanner = new HydrologyPlanner(77L, EARLY_OWNER_SETTINGS, EARLY_OWNER_TERRAIN);
|
||||
CrossTileResolvedOwner dependency = dependencyPlanner.resolveIndependentOwner(EARLY_OWNER_TILE);
|
||||
assertNull(dependency.draft().footprintCompiler());
|
||||
assertEquals(root.draft().withoutFootprintCompiler(), dependency.draft());
|
||||
assertEquals(root.observedRejections(), dependency.observedRejections());
|
||||
|
||||
int materializations = rootCompiler.fullMaterializationCount();
|
||||
assertTileContentsEqual(EARLY_OWNER_BASELINE, rootPlanner.crossTile.materializeAcceptedTile(root));
|
||||
assertTrue(rootCompiler.fullMaterializationCount() > materializations);
|
||||
assertTileContentsEqual(EARLY_OWNER_BASELINE, dependencyPlanner.plan(EARLY_OWNER_TILE));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void collidingRoutingContextKeysCompileIndependently() throws Exception {
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
@@ -251,8 +271,44 @@ public class HydrologyPlannerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void limitedWorkersSkipUnusedOwnersButResolveDemandedOwners() throws Exception {
|
||||
HydrologyTileKey ownerKey = new HydrologyTileKey(-1, -1);
|
||||
List<HydrologyTileKey> demanded = List.of(new HydrologyTileKey(-2, -2), new HydrologyTileKey(0, 0));
|
||||
int[] poolSizes = Runtime.getRuntime().availableProcessors() <= 2 ? new int[]{1, 2, 16} : new int[]{1, 2};
|
||||
for (int workers : poolSizes) {
|
||||
AtomicInteger grids = new AtomicInteger();
|
||||
HydrologyPlannerSettings settings = withPeriodTwo(standardSettings(4D, 2D, true, false, List.of()));
|
||||
HydrologyTerrainSample blocked = blockedTerrain();
|
||||
HydrologyTerrainSampler terrain = (x, z) -> blocked;
|
||||
HydrologyPlanner planner = new HydrologyPlanner(77L, settings, terrain,
|
||||
new ContextRoutingSampler(blocked, request -> grids.incrementAndGet()),
|
||||
HydrologyGeometrySampler.deterministic(terrain), -4096,
|
||||
footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096));
|
||||
AutoCloseable admission = earlyAdmission(planner, ownerKey);
|
||||
try (ForkJoinPool pool = new ForkJoinPool(workers)) {
|
||||
pool.submit(() -> {
|
||||
((CrossTileDraftAdmission) admission).prepare();
|
||||
admission.close();
|
||||
return null;
|
||||
}).get(5, TimeUnit.SECONDS);
|
||||
assertTrue(earlyTasks(admission).isEmpty());
|
||||
assertTrue(cachedOwnerKeys(planner).isEmpty());
|
||||
assertEquals("Unused owner terrain must remain unsampled", 0, grids.get());
|
||||
|
||||
List<CrossTileResolvedOwner> resolved = pool.submit(() -> planner.resolveLowerRankOwners(
|
||||
demanded, new CrossTileResolutionContext(ownerKey, 64L, 4096), Map.of()
|
||||
)).get(30, TimeUnit.SECONDS);
|
||||
assertEquals(demanded, resolved.stream().map(owner -> owner.draft().key()).toList());
|
||||
assertEquals(Set.copyOf(demanded), cachedOwnerKeys(planner));
|
||||
assertEquals(demanded.size(), grids.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void earlyOwnersPrioritizeHigherRanksAndSkipRunningOwners() throws Exception {
|
||||
org.junit.Assume.assumeTrue(Runtime.getRuntime().availableProcessors() > 2);
|
||||
HydrologyPlannerSettings settings = withPeriodTwo(standardSettings(4D, 2D, true, false, List.of()));
|
||||
HydrologyPlanner planner = new HydrologyPlanner(77L, settings, (x, z) -> blockedTerrain());
|
||||
AutoCloseable admission = earlyAdmission(planner, new HydrologyTileKey(-1, -1));
|
||||
@@ -287,7 +343,7 @@ public class HydrologyPlannerTest {
|
||||
private final ArrayList<Runnable> submitted = new ArrayList<>();
|
||||
|
||||
private CapturingHydrologyPool() {
|
||||
super(1);
|
||||
super(3);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -337,7 +393,9 @@ public class HydrologyPlannerTest {
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
assertTrue("Expected selected sources to prepare lower-rank owners", observedParallelOwner);
|
||||
if (HydrologyPlanningAdmission.effectiveParallelism(4) > 2) {
|
||||
assertTrue("Expected selected sources to prepare lower-rank owners", observedParallelOwner);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.testsupport.Await;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class HydrologyPlanningAdmissionIntegrationTest {
|
||||
@Test(timeout = 15000)
|
||||
public void separateCachesShareTheProcessPlanningBudget() throws Exception {
|
||||
ArrayList<HydrologyPlanningAdmission.Permit> held = holdRoots(HydrologyPlanningAdmission.maximumRoots() - 1);
|
||||
ExecutorService workers = Executors.newFixedThreadPool(2);
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch firstStarted = new CountDownLatch(1);
|
||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
||||
CountDownLatch secondStarted = new CountDownLatch(1);
|
||||
AtomicInteger active = new AtomicInteger();
|
||||
AtomicInteger peak = new AtomicInteger();
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyPlanner firstPlanner = mock(HydrologyPlanner.class);
|
||||
HydrologyPlanner secondPlanner = mock(HydrologyPlanner.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
when(firstPlanner.plan(key)).thenAnswer(invocation -> {
|
||||
peak.accumulateAndGet(active.incrementAndGet(), Math::max);
|
||||
firstStarted.countDown();
|
||||
assertTrue(releaseFirst.await(5L, TimeUnit.SECONDS));
|
||||
active.decrementAndGet();
|
||||
return tile;
|
||||
});
|
||||
when(secondPlanner.plan(key)).thenAnswer(invocation -> {
|
||||
peak.accumulateAndGet(active.incrementAndGet(), Math::max);
|
||||
secondStarted.countDown();
|
||||
active.decrementAndGet();
|
||||
return tile;
|
||||
});
|
||||
HydrologyTileCache firstCache = new HydrologyTileCache(firstPlanner, 4, workers);
|
||||
HydrologyTileCache secondCache = new HydrologyTileCache(secondPlanner, 4, workers);
|
||||
try {
|
||||
Future<HydrologyTile> first = callers.submit(() -> firstCache.get(key));
|
||||
assertTrue(firstStarted.await(5L, TimeUnit.SECONDS));
|
||||
Future<HydrologyTile> second = callers.submit(() -> secondCache.get(key));
|
||||
awaitLoad(secondCache);
|
||||
assertFalse(secondStarted.await(150L, TimeUnit.MILLISECONDS));
|
||||
releaseFirst.countDown();
|
||||
assertSame(tile, first.get(5L, TimeUnit.SECONDS));
|
||||
assertSame(tile, second.get(5L, TimeUnit.SECONDS));
|
||||
assertEquals(1, peak.get());
|
||||
assertEquals(0, active.get());
|
||||
} finally {
|
||||
releaseFirst.countDown();
|
||||
releaseRoots(held);
|
||||
workers.shutdownNow();
|
||||
callers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
firstCache.close();
|
||||
secondCache.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void closingAWaitingCacheCancelsAdmissionWithoutPublishingAnEmptyTile() throws Exception {
|
||||
ArrayList<HydrologyPlanningAdmission.Permit> held = holdRoots(HydrologyPlanningAdmission.maximumRoots());
|
||||
ExecutorService workers = Executors.newSingleThreadExecutor();
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2);
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
try {
|
||||
Future<HydrologyTile> pending = callers.submit(() -> cache.get(key));
|
||||
awaitLoad(cache);
|
||||
callers.submit(cache::close).get(2L, TimeUnit.SECONDS);
|
||||
ExecutionException failure = assertThrows(ExecutionException.class,
|
||||
() -> pending.get(2L, TimeUnit.SECONDS));
|
||||
assertTrue(failure.getCause() instanceof CancellationException);
|
||||
verify(planner, never()).plan(any(HydrologyTileKey.class));
|
||||
verify(planner, never()).emptyTile(any(HydrologyTileKey.class));
|
||||
assertEquals(0, cache.size());
|
||||
releaseRoots(held);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyPlanner nextPlanner = mock(HydrologyPlanner.class);
|
||||
when(nextPlanner.plan(key)).thenReturn(tile);
|
||||
try (HydrologyTileCache next = new HydrologyTileCache(nextPlanner, 4, workers)) {
|
||||
assertSame(tile, callers.submit(() -> next.get(key)).get(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
} finally {
|
||||
releaseRoots(held);
|
||||
workers.shutdownNow();
|
||||
callers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
cache.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static ArrayList<HydrologyPlanningAdmission.Permit> holdRoots(int count) {
|
||||
ArrayList<HydrologyPlanningAdmission.Permit> held = new ArrayList<>(count);
|
||||
for (int index = 0; index < count; index++) {
|
||||
held.add(HydrologyPlanningAdmission.acquireRoot(() -> false));
|
||||
}
|
||||
return held;
|
||||
}
|
||||
|
||||
private static void releaseRoots(ArrayList<HydrologyPlanningAdmission.Permit> held) {
|
||||
for (HydrologyPlanningAdmission.Permit permit : held) {
|
||||
permit.close();
|
||||
}
|
||||
held.clear();
|
||||
}
|
||||
|
||||
private static void awaitLoad(HydrologyTileCache cache) throws Exception {
|
||||
Field field = HydrologyTileCache.class.getDeclaredField("loading");
|
||||
field.setAccessible(true);
|
||||
Map<?, ?> loading = (Map<?, ?>) field.get(cache);
|
||||
Await.until("the cache load to start", Duration.ofSeconds(5L), () -> !loading.isEmpty());
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HydrologyPlanningAdmissionTest {
|
||||
@Test
|
||||
public void rootLimitAccountsForHeapAndProcessors() {
|
||||
long gibibyte = 1024L * 1024L * 1024L;
|
||||
assertEquals(1, HydrologyPlanningAdmission.rootLimit(gibibyte, 32));
|
||||
assertEquals(1, HydrologyPlanningAdmission.rootLimit(2L * gibibyte, 32));
|
||||
assertEquals(2, HydrologyPlanningAdmission.rootLimit(4L * gibibyte, 32));
|
||||
assertEquals(2, HydrologyPlanningAdmission.rootLimit(64L * gibibyte, 2));
|
||||
assertEquals(8, HydrologyPlanningAdmission.rootLimit(64L * gibibyte, 32));
|
||||
assertThrows(IllegalArgumentException.class, () -> new HydrologyPlanningAdmission(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void speculativeParallelismUsesBothWorkersAndAvailableProcessors() {
|
||||
int[][] cases = {
|
||||
{16, 1, 1}, {16, 2, 2}, {16, 4, 4}, {16, 32, 16},
|
||||
{1, 16, 1}, {2, 16, 2}, {3, 16, 3}, {4, 16, 4},
|
||||
{-1, 16, 1}, {16, 0, 1}
|
||||
};
|
||||
for (int[] parameters : cases) {
|
||||
assertEquals("pool=" + parameters[0] + " processors=" + parameters[1], parameters[2],
|
||||
HydrologyPlanningAdmission.effectiveParallelism(parameters[0], parameters[1]));
|
||||
}
|
||||
assertEquals(Runtime.getRuntime().availableProcessors(),
|
||||
HydrologyPlanningAdmission.effectiveParallelism(Integer.MAX_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void trialLimitSharesWorkingMemoryBetweenAdmittedRoots() {
|
||||
long gibibyte = 1024L * 1024L * 1024L;
|
||||
assertEquals(1, HydrologyPlanningAdmission.trialLimit(1280L * 1024L * 1024L, 1));
|
||||
assertEquals(2, HydrologyPlanningAdmission.trialLimit(2L * gibibyte, 1));
|
||||
assertEquals(2, HydrologyPlanningAdmission.trialLimit(4L * gibibyte, 2));
|
||||
assertEquals(1, HydrologyPlanningAdmission.trialLimit(gibibyte / 2L, 1));
|
||||
assertEquals(8, HydrologyPlanningAdmission.trialLimit(64L * gibibyte, 8));
|
||||
assertEquals(16, HydrologyPlanningAdmission.trialLimit(Long.MAX_VALUE, 8));
|
||||
assertThrows(IllegalArgumentException.class, () -> HydrologyPlanningAdmission.trialLimit(gibibyte, 0));
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void independentCallersShareTheSameRootBudget() throws Exception {
|
||||
HydrologyPlanningAdmission admission = new HydrologyPlanningAdmission(2);
|
||||
AtomicInteger running = new AtomicInteger();
|
||||
AtomicInteger peak = new AtomicInteger();
|
||||
CountDownLatch firstBatch = new CountDownLatch(2);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
ExecutorService callers = Executors.newFixedThreadPool(8);
|
||||
try {
|
||||
ArrayList<Future<?>> results = new ArrayList<>();
|
||||
for (int index = 0; index < 8; index++) {
|
||||
results.add(callers.submit(() -> {
|
||||
try (HydrologyPlanningAdmission.Permit ignored = admission.acquire(() -> false)) {
|
||||
peak.accumulateAndGet(running.incrementAndGet(), Math::max);
|
||||
firstBatch.countDown();
|
||||
await(release);
|
||||
running.decrementAndGet();
|
||||
}
|
||||
}));
|
||||
}
|
||||
assertTrue(firstBatch.await(5L, TimeUnit.SECONDS));
|
||||
assertEquals(2, running.get());
|
||||
release.countDown();
|
||||
for (Future<?> result : results) {
|
||||
result.get(5L, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(2, peak.get());
|
||||
assertEquals(0, running.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
callers.shutdownNow();
|
||||
assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void waitingRootsLeaveWorkersForNestedPlanning() throws Exception {
|
||||
HydrologyPlanningAdmission admission = new HydrologyPlanningAdmission(1);
|
||||
ForkJoinPool pool = new ForkJoinPool(2);
|
||||
CountDownLatch firstRoot = new CountDownLatch(1);
|
||||
CountDownLatch secondWaiting = new CountDownLatch(1);
|
||||
CountDownLatch childCompleted = new CountDownLatch(1);
|
||||
try {
|
||||
Future<List<Integer>> first = pool.submit(() -> {
|
||||
try (HydrologyPlanningAdmission.Permit ignored = admission.acquire(() -> false)) {
|
||||
firstRoot.countDown();
|
||||
await(secondWaiting);
|
||||
return HydrologyForkJoin.invokeAll(List.of(() -> {
|
||||
ForkJoinPool.managedBlock(new ForkJoinPool.ManagedBlocker() {
|
||||
@Override
|
||||
public boolean isReleasable() {
|
||||
return childCompleted.getCount() == 0L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean block() {
|
||||
await(childCompleted);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return 1;
|
||||
}, () -> {
|
||||
childCompleted.countDown();
|
||||
return 2;
|
||||
}), null);
|
||||
}
|
||||
});
|
||||
assertTrue(firstRoot.await(5L, TimeUnit.SECONDS));
|
||||
Future<Integer> second = pool.submit(() -> {
|
||||
secondWaiting.countDown();
|
||||
try (HydrologyPlanningAdmission.Permit ignored = admission.acquire(() -> false)) {
|
||||
return 3;
|
||||
}
|
||||
});
|
||||
assertEquals(List.of(1, 2), first.get(5L, TimeUnit.SECONDS));
|
||||
assertEquals(Integer.valueOf(3), second.get(5L, TimeUnit.SECONDS));
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void interruptedWaiterExitsWithoutLeakingOrReleasingAnotherRoot() throws Exception {
|
||||
HydrologyPlanningAdmission admission = new HydrologyPlanningAdmission(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
AtomicReference<Thread> waitingThread = new AtomicReference<>();
|
||||
CountDownLatch waiting = new CountDownLatch(1);
|
||||
try (HydrologyPlanningAdmission.Permit held = admission.acquire(() -> false)) {
|
||||
Future<Boolean> interrupted = caller.submit(() -> {
|
||||
waitingThread.set(Thread.currentThread());
|
||||
waiting.countDown();
|
||||
assertThrows(CancellationException.class, () -> admission.acquire(() -> false));
|
||||
return Thread.currentThread().isInterrupted();
|
||||
});
|
||||
assertTrue(waiting.await(5L, TimeUnit.SECONDS));
|
||||
waitingThread.get().interrupt();
|
||||
assertTrue(interrupted.get(5L, TimeUnit.SECONDS));
|
||||
Future<?> next = caller.submit(() -> {
|
||||
try (HydrologyPlanningAdmission.Permit ignored = admission.acquire(() -> false)) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
assertThrows(TimeoutException.class, () -> next.get(150L, TimeUnit.MILLISECONDS));
|
||||
held.close();
|
||||
next.get(5L, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void closingWhileQueuedCancelsWithoutWaitingForTheActiveRoot() throws Exception {
|
||||
HydrologyPlanningAdmission admission = new HydrologyPlanningAdmission(1);
|
||||
AtomicBoolean closed = new AtomicBoolean();
|
||||
AtomicBoolean entered = new AtomicBoolean();
|
||||
CountDownLatch waiting = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (HydrologyPlanningAdmission.Permit ignored = admission.acquire(() -> false)) {
|
||||
Future<?> cancelled = caller.submit(() -> {
|
||||
waiting.countDown();
|
||||
assertThrows(CancellationException.class, () -> {
|
||||
try (HydrologyPlanningAdmission.Permit unused = admission.acquire(closed::get)) {
|
||||
entered.set(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
assertTrue(waiting.await(5L, TimeUnit.SECONDS));
|
||||
closed.set(true);
|
||||
cancelled.get(2L, TimeUnit.SECONDS);
|
||||
assertFalse(entered.get());
|
||||
} finally {
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
try (HydrologyPlanningAdmission.Permit ignored = admission.acquire(() -> false)) {
|
||||
assertThrows(CancellationException.class, () -> admission.acquire(() -> true));
|
||||
}
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue("Timed out awaiting planning", latch.await(5L, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException interruption) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(interruption);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -15,7 +15,7 @@ import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HydrologyRegionalCacheTest {
|
||||
@Test
|
||||
public void sharedTerrainRetentionIsIndependentOfEachSearchBudget() {
|
||||
public void searchBudgetDoesNotLimitSubsequentSearchesOrTerrainResults() {
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
HydrologyTerrainSampler terrain = (x, z) -> {
|
||||
reads.incrementAndGet();
|
||||
@@ -29,17 +29,17 @@ public class HydrologyRegionalCacheTest {
|
||||
}
|
||||
assertNull(first.sample(65536, 0));
|
||||
HydrologyRegionalTerrainRefiner.Samples second = refiner.new Samples();
|
||||
for (int x = 65536; x < 100000; x++) {
|
||||
for (int x = 65536; x < 70000; x++) {
|
||||
assertNotNull(second.sample(x, 0));
|
||||
}
|
||||
assertEquals(100000, reads.get());
|
||||
for (int x = 0; x < 100000; x++) {
|
||||
assertEquals(70000, reads.get());
|
||||
for (int x = 0; x < 70000; x++) {
|
||||
assertEquals(70, refiner.sample(x, 0).naturalHeight());
|
||||
}
|
||||
assertEquals(100000, reads.get());
|
||||
int readsBeforeClear = reads.get();
|
||||
refiner.clear();
|
||||
assertEquals(70, refiner.sample(0, 0).naturalHeight());
|
||||
assertEquals(100001, reads.get());
|
||||
assertEquals(readsBeforeClear + 1, reads.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+30
@@ -9,6 +9,8 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
@@ -31,6 +33,34 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class HydrologyRegionalSourceParallelismTest {
|
||||
@Test(timeout = 15000)
|
||||
public void boundedExecutionPreservesTheWholePreselectedSourceWindow() throws Exception {
|
||||
int maximumTrials = HydrologyPlanningAdmission.maximumTrials();
|
||||
int requiredParallelism = Math.min(4, maximumTrials);
|
||||
CountDownLatch firstBatch = new CountDownLatch(requiredParallelism);
|
||||
AtomicInteger active = new AtomicInteger();
|
||||
AtomicInteger peak = new AtomicInteger();
|
||||
Set<Integer> visited = ConcurrentHashMap.newKeySet();
|
||||
Fixture fixture = fixture(4, source -> {
|
||||
peak.accumulateAndGet(active.incrementAndGet(), Math::max);
|
||||
visited.add(source);
|
||||
firstBatch.countDown();
|
||||
try {
|
||||
await(firstBatch);
|
||||
return source == 0 || source == 4 ? accepted(source) : rejected(source);
|
||||
} finally {
|
||||
active.decrementAndGet();
|
||||
}
|
||||
});
|
||||
HydrologyRegionalNetwork network = run(fixture);
|
||||
|
||||
assertEquals(List.of(100L, 104L), network.courses().stream().map(RiverCourse::id).toList());
|
||||
assertEquals(Set.of(0, 1, 2, 3, 4, 5, 6, 7), visited);
|
||||
assertTrue(peak.get() >= requiredParallelism);
|
||||
assertTrue(peak.get() <= maximumTrials);
|
||||
assertEquals(0, active.get());
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void acceptsOutletsInSourceOrderWhenTheMiddleOfAWindowSucceeds() throws Exception {
|
||||
Fixture fixture = fixture(4, source -> source == 4 || source == 1 ? accepted(source) : rejected(source));
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.spy;
|
||||
|
||||
public class HydrologyRouteHeightProbeTest {
|
||||
@Test
|
||||
public void crossDropUsesScalarHeightsIncludingMissingAndExtremeValues() {
|
||||
HydrologyTerrainSample center = land(87);
|
||||
HydrologyTerrainSample[] sides = {null, HydrologyTerrainSample.ocean(20, "ocean"),
|
||||
land(20), land(100), land(Integer.MIN_VALUE), land(Integer.MAX_VALUE)};
|
||||
for (HydrologyTerrainSample side : sides) {
|
||||
Fixture fixture = fixture((x, z) -> side);
|
||||
int lowest = side == null || side.ocean() ? 87 : Math.min(87, side.naturalHeight());
|
||||
double expected = (double) (87 - lowest) * fixture.planner().settings.routing().valleyPreference()
|
||||
* HydrologyRouteGeometry.CROSS_DROP_WEIGHT;
|
||||
double actual = fixture.planner().routeGeometry.crossDropPenalty(
|
||||
new HydrologyPoint(21, 87, -37), new RouteDirection(3D, 4D), center);
|
||||
assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual));
|
||||
assertEquals(4, fixture.scalarCalls().get());
|
||||
assertEquals(0, fixture.basisCalls().get());
|
||||
}
|
||||
Fixture untouched = fixture((x, z) -> land(20));
|
||||
assertEquals(0D, untouched.planner().routeGeometry.crossDropPenalty(
|
||||
new HydrologyPoint(0, 87, 0), new RouteDirection(0D, 0D), center), 0D);
|
||||
assertEquals(0, untouched.scalarCalls().get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pitChecksRetainMissingAndIntegerDifferenceSemantics() {
|
||||
int threshold = HydrologyPlannerSettings.defaults().hydraulics().waterfallMinimumDrop();
|
||||
HydrologyTerrainSample blocked = spy(land(threshold));
|
||||
doReturn(false).when(blocked).transitAllowed();
|
||||
HydrologyTerrainSample[] sides = {null, HydrologyTerrainSample.ocean(threshold, "ocean"),
|
||||
land(threshold - 1), land(threshold), land(Integer.MIN_VALUE), land(Integer.MAX_VALUE), blocked};
|
||||
for (int center : new int[]{0, Integer.MIN_VALUE, Integer.MAX_VALUE}) {
|
||||
for (HydrologyTerrainSample before : sides) {
|
||||
for (HydrologyTerrainSample after : sides) {
|
||||
Fixture fixture = fixture((x, z) -> x < 0 ? before : after);
|
||||
boolean expected = before != null && !before.ocean() && after != null && !after.ocean()
|
||||
&& before.naturalHeight() - center >= threshold
|
||||
&& after.naturalHeight() - center >= threshold;
|
||||
RouteCandidate candidate = new RouteCandidate(new HydrologyPoint(0, center, 0),
|
||||
0D, 0D, 0D, 0D, 0D, new RouteDirection(1D, 0D), true);
|
||||
assertEquals(expected, fixture.planner().routePaths.isTerrainPit(candidate));
|
||||
assertEquals(2, fixture.scalarCalls().get());
|
||||
assertEquals(0, fixture.basisCalls().get());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void crevassesUseScalarsWhileTransitionsStillCheckInteriorPolicy() {
|
||||
int threshold = HydrologyPlannerSettings.defaults().hydraulics().waterfallMinimumDrop();
|
||||
HydrologyTerrainSample blocked = spy(land(80));
|
||||
doReturn(false).when(blocked).transitAllowed();
|
||||
HydrologyTerrainSample[] terrain = {null, HydrologyTerrainSample.ocean(80, "ocean"),
|
||||
land(80), land(80 - threshold), land(81 - threshold), land(Integer.MIN_VALUE),
|
||||
land(Integer.MAX_VALUE), blocked};
|
||||
HydrologyPoint start = new HydrologyPoint(0, 80, 0);
|
||||
HydrologyPoint end = new HydrologyPoint(2, 80, 0);
|
||||
for (HydrologyTerrainSample endpoint : terrain) {
|
||||
for (HydrologyTerrainSample middle : terrain) {
|
||||
boolean endpointsMissing = endpoint == null || endpoint.ocean();
|
||||
boolean middleMissing = middle == null || middle.ocean();
|
||||
boolean crevasse = endpointsMissing || middleMissing
|
||||
|| endpoint.naturalHeight() - middle.naturalHeight() >= threshold;
|
||||
Fixture fixture = fixture((x, z) -> x == 1 ? middle : endpoint);
|
||||
assertEquals(crevasse, fixture.planner().routePaths.crossesTerrainCrevasse(start, end));
|
||||
assertEquals(endpointsMissing ? 2 : 3, fixture.scalarCalls().get());
|
||||
assertEquals(0, fixture.basisCalls().get());
|
||||
|
||||
fixture.scalarCalls().set(0);
|
||||
assertEquals(!crevasse && middle.transitAllowed(),
|
||||
fixture.planner().routePaths.traversableTerrainTransition(start, end));
|
||||
assertEquals(2, fixture.scalarCalls().get());
|
||||
assertEquals(endpointsMissing ? 0 : 1, fixture.basisCalls().get());
|
||||
}
|
||||
}
|
||||
Fixture adjacent = fixture((x, z) -> null);
|
||||
assertFalse(adjacent.planner().routePaths.crossesTerrainCrevasse(start, new HydrologyPoint(1, 80, 0)));
|
||||
assertEquals(0, adjacent.scalarCalls().get());
|
||||
}
|
||||
|
||||
private static HydrologyTerrainSample land(int height) {
|
||||
return HydrologyTerrainSample.openLand(height, 0D, "land");
|
||||
}
|
||||
|
||||
private static Fixture fixture(HydrologyTerrainSampler terrain) {
|
||||
AtomicInteger basisCalls = new AtomicInteger();
|
||||
AtomicInteger scalarCalls = new AtomicInteger();
|
||||
HydrologyNaturalTerrainSampler natural = new HydrologyNaturalTerrainSampler() {
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
|
||||
throw new AssertionError("Height probes do not need a routing grid.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NaturalClassification classifyNatural(int x, int z) {
|
||||
throw new AssertionError("Scalar probes already classify land.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample sampleBasis(int x, int z) {
|
||||
basisCalls.incrementAndGet();
|
||||
return terrain.sample(x, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
public double sampleLandHeight(int x, int z) {
|
||||
scalarCalls.incrementAndGet();
|
||||
HydrologyTerrainSample sampled = terrain.sample(x, z);
|
||||
return sampled == null || sampled.ocean() ? Double.NaN : sampled.naturalHeight();
|
||||
}
|
||||
};
|
||||
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
|
||||
HydrologyPlanner planner = new HydrologyPlanner(91L, settings, terrain, natural,
|
||||
HydrologyGeometrySampler.deterministic(terrain), -4096,
|
||||
footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096));
|
||||
return new Fixture(planner, basisCalls, scalarCalls);
|
||||
}
|
||||
|
||||
private record Fixture(HydrologyPlanner planner, AtomicInteger basisCalls, AtomicInteger scalarCalls) {
|
||||
}
|
||||
}
|
||||
+24
-4
@@ -19,12 +19,12 @@ public class HydrologyRouteTurnCacheTest {
|
||||
@Test
|
||||
public void repeatedRefinementMatchesExhaustiveUncachedRoutes() throws Exception {
|
||||
Random random = new Random(712319L);
|
||||
Solver solver = new Solver();
|
||||
for (int scenario = 0; scenario < 40; scenario++) {
|
||||
Solver solver = new Solver();
|
||||
int count = 4 + random.nextInt(4);
|
||||
HydrologyPoint[][] points = new HydrologyPoint[count][];
|
||||
for (int layer = 0; layer < count; layer++) {
|
||||
points[layer] = new HydrologyPoint[layer == 0 || layer == count - 1 ? 1 : 3];
|
||||
points[layer] = new HydrologyPoint[layer == 0 || layer == count - 1 ? 1 : 1 + random.nextInt(4)];
|
||||
for (int candidate = 0; candidate < points[layer].length; candidate++) {
|
||||
points[layer][candidate] = new HydrologyPoint(layer * 12 + random.nextInt(5), 80,
|
||||
candidate * 9 + random.nextInt(7));
|
||||
@@ -41,6 +41,21 @@ public class HydrologyRouteTurnCacheTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void twoLayerRefinementResetsReusedCostsAndKeepsFiniteRoutes() throws Exception {
|
||||
Random random = new Random(173L);
|
||||
HydrologyPoint[][] points = {
|
||||
{new HydrologyPoint(0, 80, 0), new HydrologyPoint(0, 80, 4)},
|
||||
{new HydrologyPoint(12, 80, 0), new HydrologyPoint(12, 80, 4)}
|
||||
};
|
||||
Solver solver = new Solver();
|
||||
double[][][][] cache = new double[2][][][];
|
||||
for (int refinement = 0; refinement < 32; refinement++) {
|
||||
Fixture fixture = fixture(points, random);
|
||||
assertArrayEquals(exhaustive(fixture, 75D), solver.solve(fixture, 75D, cache));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void straightTiesAndDegenerateSegmentsKeepOriginalFirstChoice() throws Exception {
|
||||
HydrologyPoint[][] points = {
|
||||
@@ -199,6 +214,7 @@ public class HydrologyRouteTurnCacheTest {
|
||||
private final Object tangent;
|
||||
private final Method solve;
|
||||
private final Method indices;
|
||||
private HydrologyRouteGeometry.CurvatureWorkspace workspace;
|
||||
|
||||
private Solver() throws Exception {
|
||||
Class<?> direction = RouteDirection.class;
|
||||
@@ -210,7 +226,8 @@ public class HydrologyRouteTurnCacheTest {
|
||||
double.class, double.class, double.class, direction, boolean.class);
|
||||
candidateConstructor.setAccessible(true);
|
||||
solve = HydrologyRouteGeometry.class.getDeclaredMethod("selectCurvatureAwareTerrainRoute", List.class,
|
||||
double.class, double.class, double.class, double[][].class, double[][][].class, double[][][][].class);
|
||||
double.class, double.class, double.class, double[][].class, double[][][].class, double[][][][].class,
|
||||
HydrologyRouteGeometry.CurvatureWorkspace.class);
|
||||
solve.setAccessible(true);
|
||||
indices = solve.getReturnType().getDeclaredMethod("indices");
|
||||
indices.setAccessible(true);
|
||||
@@ -227,8 +244,11 @@ public class HydrologyRouteTurnCacheTest {
|
||||
}
|
||||
layers.add(candidates);
|
||||
}
|
||||
if (workspace == null) {
|
||||
workspace = new HydrologyRouteGeometry.CurvatureWorkspace(layers);
|
||||
}
|
||||
return (int[]) indices.invoke(solve.invoke(geometry, layers, 12D, maximumTurn, 12D,
|
||||
fixture.penalties(), fixture.transitions(), cache));
|
||||
fixture.penalties(), fixture.transitions(), cache, workspace));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+87
@@ -87,6 +87,93 @@ public class HydrologySourceSpacingTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lowerPriorityHaloSamplesDoNotBuildNeighbourOwnerRouting() {
|
||||
HydrologyPlanner planner = planner();
|
||||
HydrologySampledGrid grid = haloGrid(3D, 1D);
|
||||
HydrologyRoutingPlan routing = routing(grid, Map.of());
|
||||
HashMap<HydrologyTileKey, SourceRoutingContext> contexts = new HashMap<>();
|
||||
contexts.put(TILE, new SourceRoutingContext(grid, routing, routing, List.of()));
|
||||
|
||||
assertTrue(planner.sourcePlanner.globallyAdmittedSource(grid.nodeAt(5, 5),
|
||||
planner.settings.surface().sources(), HydrologySourcePlanner.SURFACE_SOURCE_SALT,
|
||||
true, contexts, new HashMap<>()));
|
||||
|
||||
assertEquals(Set.of(TILE), contexts.keySet());
|
||||
assertEquals(0L, planner.routingContexts.estimatedSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void higherPriorityHaloSourcesStillDemandTheirOwnerRouting() {
|
||||
HydrologyPlanner planner = planner();
|
||||
HydrologySampledGrid grid = haloGrid(1D, 3D);
|
||||
HydrologyRoutingPlan routing = routing(grid, Map.of());
|
||||
SourceRoutingContext context = new SourceRoutingContext(grid, routing, routing, List.of());
|
||||
HydrologyTileKey westernOwner = new HydrologyTileKey(-1, 0);
|
||||
planner.routingContexts.put(westernOwner, context);
|
||||
HashMap<HydrologyTileKey, SourceRoutingContext> contexts = new HashMap<>();
|
||||
contexts.put(TILE, context);
|
||||
|
||||
assertFalse(planner.sourcePlanner.globallyAdmittedSource(grid.nodeAt(5, 5),
|
||||
planner.settings.surface().sources(), HydrologySourcePlanner.SURFACE_SOURCE_SALT,
|
||||
true, contexts, new HashMap<>()));
|
||||
|
||||
assertEquals(Set.of(TILE, westernOwner), contexts.keySet());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void requestDependentSamplersAlwaysUseTheOwnerGridForSourcePriority() {
|
||||
HydrologyRoutingTerrainSampler routingSampler = new HydrologyRoutingTerrainSampler() {
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
|
||||
throw new AssertionError("The owner grids are already cached");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NaturalClassification classifyNatural(int x, int z) {
|
||||
throw new AssertionError("Source spacing uses sampled routing grids");
|
||||
}
|
||||
};
|
||||
HydrologyPlannerSettings settings = planner().settings;
|
||||
HydrologyTerrainSampler terrain = (x, z) -> land(HIGH, 0D);
|
||||
HydrologyPlanner planner = new HydrologyPlanner(1337L, settings, terrain, routingSampler,
|
||||
HydrologyGeometrySampler.deterministic(terrain), -4096,
|
||||
footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096));
|
||||
HydrologySampledGrid primaryGrid = haloGrid(1D, 0D);
|
||||
HydrologySampledGrid ownerGrid = haloGrid(1D, 3D);
|
||||
HydrologyRoutingPlan primaryRouting = routing(primaryGrid, Map.of());
|
||||
HydrologyRoutingPlan ownerRouting = routing(ownerGrid, Map.of());
|
||||
SourceRoutingContext primaryContext = new SourceRoutingContext(primaryGrid, primaryRouting, primaryRouting, List.of());
|
||||
SourceRoutingContext ownerContext = new SourceRoutingContext(ownerGrid, ownerRouting, ownerRouting, List.of());
|
||||
for (HydrologyTileKey key : List.of(new HydrologyTileKey(-1, -1), new HydrologyTileKey(-1, 0),
|
||||
new HydrologyTileKey(0, -1))) {
|
||||
planner.routingContexts.put(key, ownerContext);
|
||||
}
|
||||
HashMap<HydrologyTileKey, SourceRoutingContext> contexts = new HashMap<>();
|
||||
contexts.put(TILE, primaryContext);
|
||||
|
||||
assertFalse(routingSampler.supportsSharedGridSamples());
|
||||
assertFalse(planner.sourcePlanner.globallyAdmittedSource(primaryGrid.nodeAt(5, 5),
|
||||
settings.surface().sources(), HydrologySourcePlanner.SURFACE_SOURCE_SALT,
|
||||
true, contexts, new HashMap<>()));
|
||||
assertTrue(contexts.containsKey(new HydrologyTileKey(-1, 0)));
|
||||
}
|
||||
|
||||
private static HydrologySampledGrid haloGrid(double centerWeight, double westernWeight) {
|
||||
ArrayList<HydrologyGridNode> nodes = new ArrayList<>(WIDTH * WIDTH);
|
||||
for (int gridZ = 0; gridZ < WIDTH; gridZ++) {
|
||||
for (int gridX = 0; gridX < WIDTH; gridX++) {
|
||||
int index = gridZ * WIDTH + gridX;
|
||||
int x = (gridX - 5) * SPACING;
|
||||
int z = (gridZ - 5) * SPACING;
|
||||
double weight = z != 0 ? 0D : x == 0 ? centerWeight : x == -SPACING ? westernWeight : 0D;
|
||||
nodes.add(new HydrologyGridNode(index, gridX, gridZ, x, z, index + 1L, land(HIGH, weight)));
|
||||
}
|
||||
}
|
||||
return new HydrologySampledGrid(-5 * SPACING, -5 * SPACING, 0, 0,
|
||||
HydrologyPlannerSettings.defaults().routing().tileSize(), WIDTH, SPACING, List.copyOf(nodes));
|
||||
}
|
||||
|
||||
private static Field denseField() {
|
||||
HashMap<Long, Double> weights = new HashMap<>();
|
||||
for (int gridZ = 1; gridZ < WIDTH - 1; gridZ++) {
|
||||
|
||||
+60
@@ -4,6 +4,7 @@ import org.junit.Test;
|
||||
|
||||
import java.util.NavigableSet;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
@@ -31,6 +32,65 @@ public class HydrologySurfaceBankProbeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bankHeightQueriesPreserveExactPenaltiesWithoutBankPolicySamples() {
|
||||
HydrologyTerrainSampler terrain = (x, z) -> x + z == 7 ? null
|
||||
: x < -3 ? HydrologyTerrainSample.ocean(60, "ocean")
|
||||
: HydrologyTerrainSample.openLand(80 + Math.floorMod(x * 3 + z * 7, 31), 0D, "land");
|
||||
AtomicInteger basisCalls = new AtomicInteger();
|
||||
AtomicInteger heightCalls = new AtomicInteger();
|
||||
HydrologyNaturalTerrainSampler natural = new HydrologyNaturalTerrainSampler() {
|
||||
@Override
|
||||
public HydrologyTerrainSample sampleBasis(int x, int z) {
|
||||
basisCalls.incrementAndGet();
|
||||
return terrain.sample(x, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
public double sampleLandHeight(int x, int z) {
|
||||
heightCalls.incrementAndGet();
|
||||
HydrologyTerrainSample sampled = terrain.sample(x, z);
|
||||
return sampled == null || sampled.ocean() ? Double.NaN : sampled.naturalHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
|
||||
throw new AssertionError("Bank probes do not require a routing grid");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NaturalClassification classifyNatural(int x, int z) {
|
||||
throw new AssertionError("Bank probes only require land heights");
|
||||
}
|
||||
};
|
||||
for (double angle : new double[]{0D, 0.37D, 0.7853981633974483D}) {
|
||||
HydrologyPlannerSettings settings = settings(3D);
|
||||
HydrologyPlanner reference = new HydrologyPlanner(91L, settings, terrain);
|
||||
HydrologyPlanner optimized = new HydrologyPlanner(91L, settings, terrain, natural,
|
||||
HydrologyGeometrySampler.deterministic(terrain), -4096,
|
||||
footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096));
|
||||
HydrologyPoint point = new HydrologyPoint(0, 80, 0);
|
||||
RouteCandidate candidate = new RouteCandidate(point, 0D, 0D, 0D, 0D, 0D,
|
||||
new RouteDirection(StrictMath.cos(angle), StrictMath.sin(angle)), true);
|
||||
basisCalls.set(0);
|
||||
heightCalls.set(0);
|
||||
double expected = reference.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate);
|
||||
double actual = optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate);
|
||||
assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(actual));
|
||||
assertEquals(1, basisCalls.get());
|
||||
assertTrue(heightCalls.get() > 0);
|
||||
optimized.planningSamples.set(new HydrologyPlanner.PlanningSamples());
|
||||
try {
|
||||
assertEquals(expected, optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate), 0D);
|
||||
int firstPassHeightCalls = heightCalls.get();
|
||||
assertEquals(expected, optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate), 0D);
|
||||
assertEquals(firstPassHeightCalls, heightCalls.get());
|
||||
} finally {
|
||||
optimized.planningSamples.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One block outside half the nominal maximum width - see HydrologySurfaceCoursePlanner. */
|
||||
private int innerRing() {
|
||||
return (int) StrictMath.ceil(
|
||||
|
||||
+44
@@ -7,8 +7,52 @@ import java.util.List;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
public class HydrologySurfaceRouteGeometryTest {
|
||||
@Test
|
||||
public void lazyTransitionGeometryIsReusedUntilResolvedHeightsChange() {
|
||||
HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(),
|
||||
(x, z) -> {
|
||||
throw new AssertionError("Geometry lookup must not sample terrain.");
|
||||
});
|
||||
RouteCandidate previous = candidate(0D, 0D);
|
||||
RouteCandidate current = spy(candidate(4D, 0D));
|
||||
HydrologyRouteGeometry.CurvatureWorkspace workspace = new HydrologyRouteGeometry.CurvatureWorkspace(
|
||||
List.of(List.of(previous), List.of(current)));
|
||||
double expected = planner.routePaths.routeTransitionGeometryCost(previous, current, 4D);
|
||||
clearInvocations(current);
|
||||
|
||||
for (int iteration = 0; iteration < 8; iteration++) {
|
||||
double actual = planner.routeGeometry.resolvedTransitionCost(
|
||||
previous, current, 4D, Double.NaN, workspace, 1, 0, 0);
|
||||
assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual));
|
||||
}
|
||||
verify(current, times(1)).offset();
|
||||
|
||||
RouteCandidate raised = spy(new RouteCandidate(new HydrologyPoint(4, 91, 0),
|
||||
current.continuousX(), current.continuousZ(), current.offset(), current.localScore(),
|
||||
current.terrainScore(), current.tangent(), true));
|
||||
double raisedExpected = planner.routePaths.routeTransitionGeometryCost(previous, raised, 4D);
|
||||
assertTrue(raisedExpected > expected);
|
||||
clearInvocations(raised);
|
||||
for (int iteration = 0; iteration < 8; iteration++) {
|
||||
double actual = planner.routeGeometry.resolvedTransitionCost(
|
||||
previous, raised, 4D, Double.NaN, workspace, 1, 0, 0);
|
||||
assertEquals(Double.doubleToRawLongBits(raisedExpected), Double.doubleToRawLongBits(actual));
|
||||
}
|
||||
verify(raised, times(1)).offset();
|
||||
assertEquals(Double.doubleToRawLongBits(-0D), Double.doubleToRawLongBits(
|
||||
planner.routeGeometry.resolvedTransitionCost(previous, raised, 4D, -0D,
|
||||
workspace, 1, 0, 0)));
|
||||
assertEquals(Double.POSITIVE_INFINITY,
|
||||
planner.routeGeometry.resolvedTransitionCost(previous, raised, 4D,
|
||||
Double.POSITIVE_INFINITY, workspace, 1, 0, 0), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void surfaceAnchorKeepsTheCoarseValleyWhenSmoothGroundIsMuchHigher() {
|
||||
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(
|
||||
|
||||
+204
-21
@@ -50,6 +50,181 @@ import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void weightedTileCapacityStopsSpeculationWithoutBlockingDemand() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
RiverFootprint footprint = mock(RiverFootprint.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
|
||||
when(tile.footprint()).thenReturn(footprint);
|
||||
when(footprint.size()).thenReturn((int) (HydrologyCacheBudget.runtime().tileBytes() * 3L / 5L / 64L));
|
||||
when(footprint.columns()).thenReturn(Map.of());
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 64, Runnable::run);
|
||||
int tileSize = emptySettings().routing().tileSize();
|
||||
cache.prefetchArea(0, 0, tileSize * 4 - 1, 0, 0, 0);
|
||||
cache.prefetchArea(tileSize, 0, tileSize * 4 - 1, 0, tileSize, 0);
|
||||
verify(planner, times(1)).plan(any(HydrologyTileKey.class));
|
||||
assertSame(tile, cache.get(new HydrologyTileKey(1, 0)));
|
||||
verify(planner, times(2)).plan(any(HydrologyTileKey.class));
|
||||
assertEquals(1, cache.size());
|
||||
cache.close();
|
||||
}
|
||||
|
||||
@Test(timeout = 10000L)
|
||||
public void nonblockingQueriesComposeOverBudgetTileSnapshotsOnOneWorker() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
RiverFootprint footprint = mock(RiverFootprint.class);
|
||||
HydrologyPlannerSettings settings = stalePrefetchSettings();
|
||||
when(planner.settings()).thenReturn(settings);
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
|
||||
when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty());
|
||||
when(tile.footprint()).thenReturn(footprint);
|
||||
when(footprint.size()).thenReturn((int) (HydrologyCacheBudget.runtime().tileBytes() * 3L / 5L / 64L));
|
||||
when(footprint.columns()).thenReturn(Map.of());
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 64, worker, () -> true);
|
||||
cache.setNeighbourPrefetchEnabled(false);
|
||||
try {
|
||||
cache.get(new HydrologyTileKey(10, 10));
|
||||
assertEquals(1, cache.size());
|
||||
assertFalse(cache.columnSnapshot(0, 0).available());
|
||||
worker.submit(() -> {}).get(5L, TimeUnit.SECONDS);
|
||||
|
||||
assertEquals(1, cache.size());
|
||||
assertTrue(cache.isPlanned(0, 0));
|
||||
assertTrue(cache.columnSnapshot(0, 0).available());
|
||||
assertTrue(cache.columnAt(0, 0).isEmpty());
|
||||
verify(planner, times(5)).plan(any(HydrologyTileKey.class));
|
||||
} finally {
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
cache.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nonblockingQueryQueueIsBoundedAndDeduplicatesChunks() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
|
||||
when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty());
|
||||
LinkedBlockingQueue<Runnable> queued = new LinkedBlockingQueue<>();
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 2, queued::add, () -> true);
|
||||
cache.setNeighbourPrefetchEnabled(false);
|
||||
for (int query = 0; query < 100; query++) {
|
||||
assertFalse(cache.columnSnapshot(40, 40).available());
|
||||
}
|
||||
for (int query = 1; query < 100; query++) {
|
||||
assertFalse(cache.columnSnapshot(query * 64 + 40, 40).available());
|
||||
}
|
||||
assertEquals(1, queued.size());
|
||||
verify(planner, never()).plan(any(HydrologyTileKey.class));
|
||||
drainPrefetchTasks(queued);
|
||||
verify(planner, times(2)).plan(any(HydrologyTileKey.class));
|
||||
assertTrue(cache.columnSnapshot(40, 40).available());
|
||||
assertTrue(cache.columnSnapshot(104, 40).available());
|
||||
cache.close();
|
||||
}
|
||||
|
||||
@Test(timeout = 10000L)
|
||||
public void owningWorkerClaimsCompositionQueuedBehindIt() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
|
||||
when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty());
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
ForkJoinPool worker = new ForkJoinPool(1, ForkJoinPool.defaultForkJoinWorkerThreadFactory,
|
||||
null, false, 1, 1, 1, pool -> true, 60L, TimeUnit.SECONDS);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 64, worker);
|
||||
cache.setNeighbourPrefetchEnabled(false);
|
||||
try {
|
||||
Future<Optional<HydrologyColumnSample>> result = worker.submit(() -> {
|
||||
started.countDown();
|
||||
assertTrue(release.await(5L, TimeUnit.SECONDS));
|
||||
return cache.columnAt(40, 40);
|
||||
});
|
||||
assertTrue(started.await(5L, TimeUnit.SECONDS));
|
||||
assertFalse(cache.isPlanned(40, 40));
|
||||
release.countDown();
|
||||
assertTrue(result.get(5L, TimeUnit.SECONDS).isEmpty());
|
||||
worker.submit(() -> {}).get(5L, TimeUnit.SECONDS);
|
||||
assertTrue(cache.isPlanned(40, 40));
|
||||
verify(planner, times(1)).plan(any(HydrologyTileKey.class));
|
||||
} finally {
|
||||
release.countDown();
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
cache.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 10000L)
|
||||
public void displacedCompositionTaskDoesNotCancelItsClaimedFuture() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
CountDownLatch planning = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenAnswer(invocation -> {
|
||||
planning.countDown();
|
||||
assertTrue(release.await(5L, TimeUnit.SECONDS));
|
||||
return tile;
|
||||
});
|
||||
when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty());
|
||||
LinkedBlockingQueue<Runnable> queued = new LinkedBlockingQueue<>();
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 64, queued::add);
|
||||
cache.setNeighbourPrefetchEnabled(false);
|
||||
try {
|
||||
assertFalse(cache.isPlanned(40, 40));
|
||||
Runnable displaced = queued.remove();
|
||||
Future<Optional<HydrologyColumnSample>> owner = callers.submit(() -> cache.columnAt(40, 40));
|
||||
assertTrue(planning.await(5L, TimeUnit.SECONDS));
|
||||
Future<Optional<HydrologyColumnSample>> waiter = callers.submit(() -> cache.columnAt(40, 40));
|
||||
assertThrows(TimeoutException.class, () -> waiter.get(100L, TimeUnit.MILLISECONDS));
|
||||
displaced.run();
|
||||
assertThrows(TimeoutException.class, () -> waiter.get(100L, TimeUnit.MILLISECONDS));
|
||||
release.countDown();
|
||||
assertTrue(owner.get(5L, TimeUnit.SECONDS).isEmpty());
|
||||
assertTrue(waiter.get(5L, TimeUnit.SECONDS).isEmpty());
|
||||
verify(planner, times(1)).plan(any(HydrologyTileKey.class));
|
||||
} finally {
|
||||
release.countDown();
|
||||
callers.shutdownNow();
|
||||
assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
cache.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clearingAndClosingCancelQueuedNonblockingComposition() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
|
||||
when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty());
|
||||
LinkedBlockingQueue<Runnable> queued = new LinkedBlockingQueue<>();
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 2, queued::add, () -> true);
|
||||
cache.setNeighbourPrefetchEnabled(false);
|
||||
assertFalse(cache.columnSnapshot(40, 40).available());
|
||||
cache.clear();
|
||||
assertFalse(cache.columnSnapshot(104, 40).available());
|
||||
drainPrefetchTasks(queued);
|
||||
verify(planner, never()).plan(new HydrologyTileKey(0, 0));
|
||||
verify(planner, times(1)).plan(new HydrologyTileKey(1, 0));
|
||||
assertTrue(cache.columnSnapshot(104, 40).available());
|
||||
assertFalse(cache.columnSnapshot(168, 40).available());
|
||||
cache.close();
|
||||
drainPrefetchTasks(queued);
|
||||
verify(planner, never()).plan(new HydrologyTileKey(2, 0));
|
||||
assertEquals(0, cache.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void interruptedWaiterExitsWithoutCancellingTheSharedPlan() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
@@ -957,17 +1132,18 @@ public class HydrologyTileCacheTest {
|
||||
assertTrue(order.subList(0, 4).containsAll(List.of(new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 0), new HydrologyTileKey(0, 1), new HydrologyTileKey(1, 1))));
|
||||
}
|
||||
@Test
|
||||
public void tileBatchesPlanMissingTilesConcurrentlyOnThePrefetchExecutor() throws Exception {
|
||||
public void tileBatchesUseTheAvailableRootBudgetOnThePrefetchExecutor() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
AtomicInteger inFlight = new AtomicInteger();
|
||||
AtomicInteger peak = new AtomicInteger();
|
||||
CountDownLatch allStarted = new CountDownLatch(4);
|
||||
int admitted = Math.min(4, HydrologyPlanningAdmission.maximumRoots());
|
||||
CountDownLatch allStarted = new CountDownLatch(admitted);
|
||||
doAnswer(invocation -> {
|
||||
peak.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
|
||||
allStarted.countDown();
|
||||
assertTrue("every batch member should plan at the same time", allStarted.await(5, TimeUnit.SECONDS));
|
||||
assertTrue("the admitted batch should plan concurrently", allStarted.await(5, TimeUnit.SECONDS));
|
||||
inFlight.decrementAndGet();
|
||||
return tile;
|
||||
}).when(planner).plan(any(HydrologyTileKey.class));
|
||||
@@ -984,7 +1160,7 @@ public class HydrologyTileCacheTest {
|
||||
List<HydrologyTile> tiles = cache.tiles(keys);
|
||||
|
||||
assertEquals(4, tiles.size());
|
||||
assertEquals(4, peak.get());
|
||||
assertEquals(admitted, peak.get());
|
||||
verify(planner, times(4)).plan(any(HydrologyTileKey.class));
|
||||
assertEquals(4, cache.size());
|
||||
} finally {
|
||||
@@ -1007,7 +1183,7 @@ public class HydrologyTileCacheTest {
|
||||
private void assertConcurrentTilePlanning(List<HydrologyTileKey> keys) throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
CountDownLatch started = new CountDownLatch(keys.size());
|
||||
CountDownLatch started = new CountDownLatch(Math.min(keys.size(), HydrologyPlanningAdmission.maximumRoots()));
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
doAnswer(invocation -> {
|
||||
started.countDown();
|
||||
@@ -1019,7 +1195,7 @@ public class HydrologyTileCacheTest {
|
||||
try {
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 64, executor);
|
||||
Future<List<HydrologyTile>> result = caller.submit(() -> cache.tiles(keys));
|
||||
assertTrue("colliding keys must start before either plan completes", started.await(5, TimeUnit.SECONDS));
|
||||
assertTrue("colliding keys must use the available root budget", started.await(5, TimeUnit.SECONDS));
|
||||
release.countDown();
|
||||
assertEquals(keys.size(), result.get(5, TimeUnit.SECONDS).size());
|
||||
for (HydrologyTileKey key : keys) {
|
||||
@@ -1138,8 +1314,9 @@ public class HydrologyTileCacheTest {
|
||||
Future<HydrologyTile> oldResult = callers.submit(() -> cache.get(key));
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
cache.clear();
|
||||
assertSame(newTile, callers.submit(() -> cache.get(key)).get(5, TimeUnit.SECONDS));
|
||||
Future<HydrologyTile> newResult = callers.submit(() -> cache.get(key));
|
||||
release.countDown();
|
||||
assertSame(newTile, newResult.get(5, TimeUnit.SECONDS));
|
||||
assertSame(oldTile, oldResult.get(5, TimeUnit.SECONDS));
|
||||
assertSame(newTile, cache.get(key));
|
||||
assertEquals(2, calls.get());
|
||||
@@ -1502,17 +1679,27 @@ public class HydrologyTileCacheTest {
|
||||
List<HydrologyTileKey> keys = List.of(
|
||||
new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 0), new HydrologyTileKey(2, 0));
|
||||
Future<List<HydrologyTile>> result = caller.submit(() -> cache.tiles(keys));
|
||||
Runnable firstDemand = queued.poll(5, TimeUnit.SECONDS);
|
||||
Runnable secondDemand = queued.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(firstDemand);
|
||||
assertNotNull(secondDemand);
|
||||
|
||||
Field demandField = HydrologyTileCache.class.getDeclaredField("demandBatches");
|
||||
Field queueField = HydrologyTileCache.class.getDeclaredField("prefetchQueue");
|
||||
demandField.setAccessible(true);
|
||||
queueField.setAccessible(true);
|
||||
Object demandLock = queueField.get(cache);
|
||||
Await.until("the demand batch to register", Duration.ofSeconds(5L), () -> {
|
||||
synchronized (demandLock) {
|
||||
try {
|
||||
return demandField.getInt(cache) > 0;
|
||||
} catch (IllegalAccessException failure) {
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}
|
||||
});
|
||||
initialPrefetch.run();
|
||||
assertTrue(queued.isEmpty());
|
||||
assertSame(tile, cache.get(new HydrologyTileKey(0, 0)));
|
||||
assertTrue(queued.isEmpty());
|
||||
Runnable firstDemand = queued.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(firstDemand);
|
||||
firstDemand.run();
|
||||
assertTrue(queued.isEmpty());
|
||||
Runnable secondDemand = queued.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(secondDemand);
|
||||
secondDemand.run();
|
||||
assertEquals(3, result.get(5, TimeUnit.SECONDS).size());
|
||||
Runnable resumedPrefetch = queued.poll(5, TimeUnit.SECONDS);
|
||||
@@ -1788,12 +1975,8 @@ public class HydrologyTileCacheTest {
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
|
||||
List<Runnable> queued = new ArrayList<>();
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 64, queued::add, () -> true);
|
||||
assertFalse(cache.columnSnapshot(40, 40).available());
|
||||
for (Runnable task : List.copyOf(queued)) {
|
||||
task.run();
|
||||
}
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 64, null, () -> true);
|
||||
cache.get(new HydrologyTileKey(0, 0));
|
||||
AtomicBoolean evicted = new AtomicBoolean();
|
||||
when(tile.columnAt(anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
if (evicted.compareAndSet(false, true)) {
|
||||
|
||||
+65
@@ -1,6 +1,14 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
|
||||
import art.arcane.iris.generation.hydrology.cave.CavePosition;
|
||||
import art.arcane.iris.generation.hydrology.cave.CaveVoxel;
|
||||
import art.arcane.iris.generation.hydrology.cave.CaveVoxelPrecondition;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveMode;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveRejection;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveSource;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
@@ -17,6 +25,9 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
@@ -50,6 +61,60 @@ public class StudioHydrologyTileStoreTest {
|
||||
assertThrows(UnsupportedOperationException.class, () -> restored.regionalCourseIds().clear());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void roundTripsNonemptyCompactCavePlan() throws Exception {
|
||||
CavePosition wet = new CavePosition(0, 20, 0);
|
||||
CavePosition dry = new CavePosition(0, 21, 0);
|
||||
CavePosition guard = new CavePosition(Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE);
|
||||
CavePosition boundary = new CavePosition(Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE);
|
||||
LinkedHashMap<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
|
||||
actions.put(guard, HydrologyCaveAction.SEAL_GUARD);
|
||||
actions.put(dry, HydrologyCaveAction.DRY_AIR);
|
||||
actions.put(wet, HydrologyCaveAction.WET_SOURCE);
|
||||
LinkedHashMap<CavePosition, CaveVoxelPrecondition> preconditions = new LinkedHashMap<>();
|
||||
preconditions.put(wet, new CaveVoxelPrecondition(CaveVoxel.SOLID, false));
|
||||
preconditions.put(boundary, new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, true));
|
||||
preconditions.put(guard, new CaveVoxelPrecondition(CaveVoxel.LAVA, false));
|
||||
preconditions.put(dry, new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, false));
|
||||
HydrologyCavePlan plan = new HydrologyCavePlan(
|
||||
new HydrologyCaveSource(4L, wet, dry, 20, HydrologyCaveMode.CLOSED_COMPONENT),
|
||||
HydrologyCaveRejection.NONE, actions, preconditions, OptionalLong.empty());
|
||||
HydraulicSegment segment = new HydraulicSegment(3L, 4L, HydrologyFeatureType.UNDERGROUND_POOL,
|
||||
20, 20, 1, 1, false, false, List.of(new HydrologyPoint(0, 20, 0)),
|
||||
new HydraulicChannelProfile(new double[]{1D}, new double[]{1D}));
|
||||
RiverCourse course = new RiverCourse(4L, RiverCourseType.SEA_CAVE, OptionalLong.empty(),
|
||||
OptionalLong.empty(), "default", 1, List.of(), List.of(segment));
|
||||
HydrologyFeatureRef feature = new HydrologyFeatureRef(5L, HydrologyFeatureType.UNDERGROUND_POOL,
|
||||
4L, 3L, 0, 20, 0, 0, 0, true);
|
||||
HydrologyColumnLayer layer = new HydrologyColumnLayer(feature, 19, 20, 21,
|
||||
true, false, false, true, false, false, true, true, false,
|
||||
"default", "plains", "plains", "plains", "plains", "plains");
|
||||
HydrologyColumnSample column = new HydrologyColumnSample(0, 0, 80, 63, false, "plains", List.of(layer));
|
||||
HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 91L, 17L, 64,
|
||||
List.of(), List.of(), List.of(), List.of(course), Set.of(), List.of(plan), List.of(),
|
||||
new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), column)));
|
||||
StudioHydrologyTileStore store = store();
|
||||
store.save(original);
|
||||
HydrologyTile restored = store.load(original.key()).orElseThrow();
|
||||
assertEquals(original, restored);
|
||||
HydrologyCavePlan restoredPlan = restored.cavePlans().getFirst();
|
||||
assertEquals(actions, restoredPlan.actions());
|
||||
assertEquals(preconditions, restoredPlan.baselinePreconditions());
|
||||
Comparator<CavePosition> persistedOrder = Comparator.comparingInt(CavePosition::x)
|
||||
.thenComparingInt(CavePosition::z).thenComparingInt(CavePosition::y);
|
||||
ArrayList<CavePosition> expectedActions = new ArrayList<>(actions.keySet());
|
||||
expectedActions.sort(persistedOrder);
|
||||
ArrayList<CavePosition> expectedPreconditions = new ArrayList<>(preconditions.keySet());
|
||||
expectedPreconditions.sort(persistedOrder);
|
||||
assertEquals(expectedActions, new ArrayList<>(restoredPlan.actions().keySet()));
|
||||
assertEquals(expectedPreconditions, new ArrayList<>(restoredPlan.baselinePreconditions().keySet()));
|
||||
assertThrows(UnsupportedOperationException.class, () -> restoredPlan.actions().clear());
|
||||
assertThrows(UnsupportedOperationException.class, () -> restoredPlan.baselinePreconditions().remove(boundary));
|
||||
ArrayList<CavePosition> published = new ArrayList<>();
|
||||
restoredPlan.forEachActionIn(0, 0, 16, 16, (position, action) -> published.add(position));
|
||||
assertEquals(List.of(wet, dry), published);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void entryRequiresRegionalOwnershipMetadata() throws Exception {
|
||||
HydrologyTile original = tile();
|
||||
|
||||
+133
@@ -7,9 +7,14 @@ import java.util.OptionalLong;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
@@ -84,6 +89,134 @@ public class HydrologyCavePlanTest {
|
||||
assertEquals(List.of(inside, preconditionOnly), visitedPreconditions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void compactMapsPreserveAllCoordinateBitsAndIndependentOrders() {
|
||||
Random random = new Random(718622L);
|
||||
ArrayList<CavePosition> positions = new ArrayList<>();
|
||||
for (int x : new int[]{Integer.MIN_VALUE, -1, 0, Integer.MAX_VALUE}) {
|
||||
for (int y : new int[]{Integer.MIN_VALUE, -1, 0, Integer.MAX_VALUE}) {
|
||||
for (int z : new int[]{Integer.MIN_VALUE, -1, 0, Integer.MAX_VALUE}) {
|
||||
positions.add(new CavePosition(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int index = 0; index < 4096; index++) {
|
||||
positions.add(new CavePosition(random.nextInt(), random.nextInt(), random.nextInt()));
|
||||
}
|
||||
LinkedHashMap<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
|
||||
LinkedHashMap<CavePosition, CaveVoxelPrecondition> preconditions = new LinkedHashMap<>();
|
||||
HydrologyCaveAction[] actionTypes = HydrologyCaveAction.values();
|
||||
CaveVoxel[] voxels = CaveVoxel.values();
|
||||
for (int index = 0; index < positions.size(); index++) {
|
||||
if (index % 3 != 0) {
|
||||
actions.put(positions.get(index), actionTypes[index % actionTypes.length]);
|
||||
}
|
||||
}
|
||||
Collections.shuffle(positions, random);
|
||||
for (int index = 0; index < positions.size(); index++) {
|
||||
preconditions.put(positions.get(index), new CaveVoxelPrecondition(voxels[index % voxels.length], index % 2 == 0));
|
||||
}
|
||||
HydrologyCavePlan plan = new HydrologyCavePlan(source(), HydrologyCaveRejection.NONE,
|
||||
actions, preconditions, OptionalLong.empty());
|
||||
assertEquals(actions, plan.actions());
|
||||
assertEquals(plan.actions(), actions);
|
||||
assertEquals(actions.hashCode(), plan.actions().hashCode());
|
||||
assertEquals(preconditions, plan.baselinePreconditions());
|
||||
assertEquals(plan.baselinePreconditions(), preconditions);
|
||||
assertEquals(preconditions.hashCode(), plan.baselinePreconditions().hashCode());
|
||||
assertEquals(new ArrayList<>(actions.entrySet()), new ArrayList<>(plan.actions().entrySet()));
|
||||
assertEquals(new ArrayList<>(preconditions.entrySet()), new ArrayList<>(plan.baselinePreconditions().entrySet()));
|
||||
assertEquals(new ArrayList<>(actions.values()), new ArrayList<>(plan.actions().values()));
|
||||
assertEquals(new ArrayList<>(preconditions.values()), new ArrayList<>(plan.baselinePreconditions().values()));
|
||||
assertTrue(plan.allPreconditions((position, baseline) -> baseline.equals(preconditions.get(position))));
|
||||
LinkedHashMap<CavePosition, HydrologyCaveAction> visitedActions = new LinkedHashMap<>();
|
||||
LinkedHashMap<CavePosition, CaveVoxelPrecondition> visitedPreconditions = new LinkedHashMap<>();
|
||||
plan.forEachAction(visitedActions::put);
|
||||
plan.forEachPrecondition(visitedPreconditions::put);
|
||||
assertEquals(new ArrayList<>(actions.entrySet()), new ArrayList<>(visitedActions.entrySet()));
|
||||
assertEquals(new ArrayList<>(preconditions.entrySet()), new ArrayList<>(visitedPreconditions.entrySet()));
|
||||
assertNull(plan.actions().get("absent"));
|
||||
assertNull(plan.baselinePreconditions().get(new CavePosition(42, 43, 44)));
|
||||
actions.clear();
|
||||
preconditions.clear();
|
||||
assertEquals(visitedActions, plan.actions());
|
||||
assertEquals(visitedPreconditions, plan.baselinePreconditions());
|
||||
assertThrows(UnsupportedOperationException.class, () -> plan.actions().entrySet().iterator().next().setValue(HydrologyCaveAction.DRY_AIR));
|
||||
assertThrows(UnsupportedOperationException.class, () -> plan.baselinePreconditions().values().clear());
|
||||
Iterator<CavePosition> iterator = plan.actions().keySet().iterator();
|
||||
iterator.next();
|
||||
assertThrows(UnsupportedOperationException.class, iterator::remove);
|
||||
while (iterator.hasNext()) {
|
||||
iterator.next();
|
||||
}
|
||||
assertThrows(NoSuchElementException.class, iterator::next);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void spatialQueriesPreserveExtremeCoordinatesAndChunkOrder() {
|
||||
List<CavePosition> positions = List.of(
|
||||
new CavePosition(Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE),
|
||||
new CavePosition(Integer.MAX_VALUE - 1, Integer.MIN_VALUE, Integer.MAX_VALUE - 1),
|
||||
new CavePosition(16, 1, 16),
|
||||
new CavePosition(-1, 2, 0),
|
||||
new CavePosition(0, 3, -1),
|
||||
new CavePosition(1, 4, 1),
|
||||
new CavePosition(1, 5, 1)
|
||||
);
|
||||
LinkedHashMap<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
|
||||
LinkedHashMap<CavePosition, CaveVoxelPrecondition> preconditions = new LinkedHashMap<>();
|
||||
CaveVoxelPrecondition baseline = new CaveVoxelPrecondition(CaveVoxel.SOLID, false);
|
||||
for (CavePosition position : positions) {
|
||||
actions.put(position, HydrologyCaveAction.WET_SOURCE);
|
||||
}
|
||||
for (CavePosition position : positions.reversed()) {
|
||||
preconditions.put(position, baseline);
|
||||
}
|
||||
HydrologyCavePlan plan = new HydrologyCavePlan(source(), HydrologyCaveRejection.NONE,
|
||||
actions, preconditions, OptionalLong.empty());
|
||||
ArrayList<CavePosition> visited = new ArrayList<>();
|
||||
plan.forEachActionIn(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE + 1, Integer.MIN_VALUE + 1,
|
||||
(position, action) -> visited.add(position));
|
||||
assertEquals(List.of(positions.getFirst()), visited);
|
||||
visited.clear();
|
||||
plan.forEachActionIn(Integer.MAX_VALUE - 1, Integer.MAX_VALUE - 1, Integer.MAX_VALUE, Integer.MAX_VALUE,
|
||||
(position, action) -> visited.add(position));
|
||||
assertEquals(List.of(positions.get(1)), visited);
|
||||
visited.clear();
|
||||
plan.forEachActionIn(-16, -16, 32, 32, (position, action) -> visited.add(position));
|
||||
List<CavePosition> expectedOrder = List.of(positions.get(4), positions.get(3), positions.get(5), positions.get(6), positions.get(2));
|
||||
assertEquals(expectedOrder, visited);
|
||||
visited.clear();
|
||||
assertTrue(plan.allPreconditionsIn(-16, -16, 32, 32, (position, precondition) -> {
|
||||
visited.add(position);
|
||||
return true;
|
||||
}));
|
||||
assertEquals(expectedOrder, visited);
|
||||
assertFalse(plan.allPreconditionsIn(-16, -16, 32, 32, (position, precondition) -> false));
|
||||
assertTrue(plan.intersectsActions(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE + 1, Integer.MIN_VALUE + 1));
|
||||
assertFalse(plan.intersectsActions(2, 2, 16, 16));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainedSizeIncludesPrimitiveLookupAndSpatialStorage() {
|
||||
LinkedHashMap<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
|
||||
LinkedHashMap<CavePosition, CaveVoxelPrecondition> preconditions = new LinkedHashMap<>();
|
||||
CaveVoxelPrecondition baseline = new CaveVoxelPrecondition(CaveVoxel.SOLID, false);
|
||||
for (int y = 0; y < 1024; y++) {
|
||||
CavePosition position = new CavePosition(0, y, 0);
|
||||
actions.put(position, HydrologyCaveAction.WET_SOURCE);
|
||||
preconditions.put(position, baseline);
|
||||
}
|
||||
HydrologyCavePlan plan = new HydrologyCavePlan(source(), HydrologyCaveRejection.NONE,
|
||||
actions, preconditions, OptionalLong.empty());
|
||||
long estimated = plan.estimatedRetainedBytes();
|
||||
assertTrue(estimated > 13L * preconditions.size());
|
||||
assertTrue(estimated < 48L * preconditions.size());
|
||||
assertEquals(estimated, plan.estimatedRetainedBytes());
|
||||
assertEquals(actions, plan.actions());
|
||||
assertEquals(preconditions, plan.baselinePreconditions());
|
||||
}
|
||||
|
||||
private HydrologyCaveSource source() {
|
||||
return new HydrologyCaveSource(
|
||||
1L,
|
||||
|
||||
+209
@@ -3,8 +3,18 @@ package art.arcane.iris.generation.hydrology.runtime;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
|
||||
import art.arcane.iris.generation.concurrent.MultiBurst;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.terrain.InferredType;
|
||||
import art.arcane.iris.generation.terrain.IrisRegion;
|
||||
import art.arcane.iris.studio.generation.BiomeBuffetLayout;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -20,8 +30,101 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class IrisHydrologyRoutingTerrainSamplerTest {
|
||||
@Test
|
||||
public void bankHeightsAtSeaLevelSkipOceanIntentSampling() {
|
||||
AtomicInteger classifierCalls = new AtomicInteger();
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources(
|
||||
(int x, int z, double naturalHeight) -> {
|
||||
throw new AssertionError("Scalar bank sampling must not load a terrain basis.");
|
||||
},
|
||||
(int x, int z) -> x == 0 ? 62.5D : 62.49D,
|
||||
(int x, int z) -> {
|
||||
classifierCalls.incrementAndGet();
|
||||
return true;
|
||||
},
|
||||
63
|
||||
),
|
||||
IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(16)
|
||||
);
|
||||
|
||||
assertEquals(63D, sampler.sampleLandHeight(0, 0), 0D);
|
||||
assertEquals(0, classifierCalls.get());
|
||||
assertTrue(Double.isNaN(sampler.sampleLandHeight(1, 0)));
|
||||
assertEquals(1, classifierCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buffetBankHeightsMatchFullBasisBeforeAndAfterCacheWarmup() throws Exception {
|
||||
IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS);
|
||||
IrisBiome biome = new IrisBiome().setInferredType(InferredType.LAND);
|
||||
IrisRegion region = new IrisRegion();
|
||||
BiomeBuffetLayout buffet = mock(BiomeBuffetLayout.class);
|
||||
doReturn(new BiomeBuffetLayout.Cell(biome, region)).when(buffet).terrain(anyDouble(), anyDouble());
|
||||
ProceduralStream<InferredType> bridge = ProceduralStream.of(
|
||||
(x, z) -> InferredType.SEA,
|
||||
Interpolated.of(value -> 0D, value -> InferredType.SEA)
|
||||
);
|
||||
ProceduralStream<IrisRegion> regions = ProceduralStream.of(
|
||||
(x, z) -> region,
|
||||
Interpolated.of(value -> 0D, value -> region)
|
||||
);
|
||||
setComplexField(complex, "biomeBuffet", buffet);
|
||||
setComplexField(complex, "bridgeStream", bridge);
|
||||
setComplexField(complex, "regionStream", regions);
|
||||
Method natural = IrisComplex.class.getDeclaredMethod(
|
||||
"sampleHydrologyNatural", Engine.class, int.class, int.class, double.class
|
||||
);
|
||||
Method ocean = IrisComplex.class.getDeclaredMethod("sampleNaturalOcean", int.class, int.class);
|
||||
natural.setAccessible(true);
|
||||
ocean.setAccessible(true);
|
||||
|
||||
for (boolean warmBasis : new boolean[]{false, true}) {
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources(
|
||||
(int x, int z, double height) -> {
|
||||
try {
|
||||
IrisHydrologyNaturalSample sample = (IrisHydrologyNaturalSample)
|
||||
natural.invoke(complex, null, x, z, height);
|
||||
return sample.ocean() ? oceanBasis(x, z, height) : basis(x, z, height);
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
},
|
||||
(int x, int z) -> 60.25D,
|
||||
(int x, int z) -> {
|
||||
try {
|
||||
return (boolean) ocean.invoke(complex, x, z);
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
},
|
||||
63
|
||||
),
|
||||
IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(64)
|
||||
);
|
||||
if (warmBasis) {
|
||||
sampler.sampleBasisWithoutSlope(7, 11);
|
||||
}
|
||||
assertEquals(60D, sampler.sampleLandHeight(7, 11), 0D);
|
||||
HydrologyTerrainSample full = sampler.sampleBasisWithoutSlope(7, 11);
|
||||
assertEquals(false, full.ocean());
|
||||
assertEquals(full.naturalHeight(), sampler.sampleLandHeight(7, 11), 0D);
|
||||
}
|
||||
}
|
||||
|
||||
private static void setComplexField(IrisComplex complex, String name, Object value) throws Exception {
|
||||
Field field = IrisComplex.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(complex, value);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void guardedGridSamplesOneAlignedBasisAtEachCoordinate() {
|
||||
int[] spacings = new int[]{64, 128, 256};
|
||||
@@ -137,6 +240,82 @@ public class IrisHydrologyRoutingTerrainSamplerTest {
|
||||
assertEquals(sloped, slopeFree.withSlope(sloped.slope()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landHeightProbesMatchFullTerrainWithoutBuildingPolicySamples() {
|
||||
AtomicInteger basisCalls = new AtomicInteger();
|
||||
AtomicInteger heightCalls = new AtomicInteger();
|
||||
int seaLevel = 63;
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources(
|
||||
(int x, int z, double naturalHeight) -> {
|
||||
basisCalls.incrementAndGet();
|
||||
boolean ocean = IrisHydrologyRoutingTerrainSampler.physicalOcean(x % 2 == 0, naturalHeight, seaLevel);
|
||||
return ocean ? oceanBasis(x, z, naturalHeight) : basis(x, z, naturalHeight);
|
||||
},
|
||||
(int x, int z) -> {
|
||||
heightCalls.incrementAndGet();
|
||||
return seaLevel + x * 0.5D;
|
||||
},
|
||||
(int x, int z) -> x % 2 == 0,
|
||||
seaLevel
|
||||
), IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(128));
|
||||
for (int x = -20; x <= 20; x++) {
|
||||
double expected = x % 2 == 0 && StrictMath.round(seaLevel + x * 0.5D) < seaLevel
|
||||
? Double.NaN : (int) StrictMath.round(seaLevel + x * 0.5D);
|
||||
assertEquals(expected, sampler.sampleLandHeight(x, -17), 0D);
|
||||
assertEquals(expected, sampler.sampleLandHeight(x, -17), 0D);
|
||||
}
|
||||
assertEquals(0, basisCalls.get());
|
||||
assertEquals(41, heightCalls.get());
|
||||
for (int x = -20; x <= 20; x++) {
|
||||
HydrologyTerrainSample terrain = sampler.sampleBasisWithoutSlope(x, -17);
|
||||
assertEquals(terrain.ocean() ? Double.NaN : terrain.naturalHeight(), sampler.sampleLandHeight(x, -17), 0D);
|
||||
}
|
||||
assertEquals(41, basisCalls.get());
|
||||
assertEquals(41, heightCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landHeightProbesPassNonFiniteSamplesThroughTheExistingBasisRecovery() {
|
||||
AtomicInteger heightCalls = new AtomicInteger();
|
||||
AtomicInteger basisCalls = new AtomicInteger();
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources(
|
||||
(int x, int z, double naturalHeight) -> {
|
||||
assertTrue(Double.isNaN(naturalHeight));
|
||||
basisCalls.incrementAndGet();
|
||||
return basis(x, z, 87D);
|
||||
},
|
||||
(int x, int z) -> {
|
||||
heightCalls.incrementAndGet();
|
||||
return Double.NaN;
|
||||
},
|
||||
(int x, int z) -> true,
|
||||
63
|
||||
), IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(16));
|
||||
|
||||
assertEquals(87D, sampler.sampleLandHeight(-5, 9), 0D);
|
||||
assertEquals(87D, sampler.sampleLandHeight(-5, 9), 0D);
|
||||
assertEquals(1, heightCalls.get());
|
||||
assertEquals(1, basisCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedLandHeightProbesDoNotMemoizeANonFiniteHeight() {
|
||||
AtomicInteger heightCalls = new AtomicInteger();
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources(
|
||||
(int x, int z, double naturalHeight) -> basis(x, z, naturalHeight),
|
||||
(int x, int z) -> heightCalls.incrementAndGet() == 1 ? Double.NaN : 87D,
|
||||
(int x, int z) -> false,
|
||||
63
|
||||
), IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(16));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> sampler.sampleLandHeight(-5, 9));
|
||||
assertEquals(87D, sampler.sampleLandHeight(-5, 9), 0D);
|
||||
assertEquals(2, heightCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void adjacentGridsShareBasesAndProduceIdenticalOverlap() {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
@@ -167,6 +346,36 @@ public class IrisHydrologyRoutingTerrainSamplerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedGridSamplesMatchAtPaddedEdgesAndNegativeOwnershipSeams() {
|
||||
IrisHydrologyRoutingTerrainSampler sampler = sampler(4096);
|
||||
assertTrue(sampler.supportsSharedGridSamples());
|
||||
int spacing = 64;
|
||||
int width = 5;
|
||||
int primaryMinimum = -128;
|
||||
HydrologyTerrainSample[] primary = sampler.sampleGrid(new HydrologyRoutingTerrainSampler.GridRequest(
|
||||
primaryMinimum, primaryMinimum, width, spacing));
|
||||
for (int shiftX : new int[]{-128, 128}) {
|
||||
for (int shiftZ : new int[]{-128, 128}) {
|
||||
int minimumX = primaryMinimum + shiftX;
|
||||
int minimumZ = primaryMinimum + shiftZ;
|
||||
HydrologyTerrainSample[] adjacent = sampler.sampleGrid(new HydrologyRoutingTerrainSampler.GridRequest(
|
||||
minimumX, minimumZ, width, spacing));
|
||||
int firstX = Math.max(primaryMinimum, minimumX);
|
||||
int firstZ = Math.max(primaryMinimum, minimumZ);
|
||||
int lastX = Math.min(primaryMinimum + (width - 1) * spacing, minimumX + (width - 1) * spacing);
|
||||
int lastZ = Math.min(primaryMinimum + (width - 1) * spacing, minimumZ + (width - 1) * spacing);
|
||||
for (int z = firstZ; z <= lastZ; z += spacing) {
|
||||
for (int x = firstX; x <= lastX; x += spacing) {
|
||||
int primaryIndex = (z - primaryMinimum) / spacing * width + (x - primaryMinimum) / spacing;
|
||||
int adjacentIndex = (z - minimumZ) / spacing * width + (x - minimumX) / spacing;
|
||||
assertSampleRawBits(primary[primaryIndex], adjacent[adjacentIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void serialGridKeepsRowMajorProviderCallOrder() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
|
||||
+52
@@ -20,6 +20,7 @@ import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
@@ -303,6 +304,57 @@ public class SurfaceFootprintCompilerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preparedWindowsRetainColumnsAdmissionAndExcavationAcrossFallsAndMouths() {
|
||||
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 190
|
||||
? HydrologyTerrainSample.ocean(50, "ocean")
|
||||
: HydrologyTerrainSample.openLand((x < 100 ? 100 : 90) + Math.abs(z) / 3, 0D, "land");
|
||||
SurfaceCourseResult built = new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler,
|
||||
CONSTANT_GEOMETRY, SEA_LEVEL).build(7L, 1L, "water",
|
||||
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(180, 0, 0)),
|
||||
SurfaceTerminal.TRIBUTARY, 40, 64);
|
||||
assertNull(built.rejection());
|
||||
RiverCourse falling = course(built.segments());
|
||||
RiverCourse mouth = course(List.of(segment(1L, HydrologyFeatureType.MOUTH, SEA_LEVEL, SEA_LEVEL,
|
||||
points(180, 200, SEA_LEVEL))));
|
||||
RiverCourse curved = course(List.of(segment(1L, HydrologyFeatureType.SURFACE_POOL, 79, 79,
|
||||
List.of(new HydrologyPoint(-100, 79, -50), new HydrologyPoint(0, 79, 30),
|
||||
new HydrologyPoint(100, 79, -50)))));
|
||||
for (RiverCourse course : List.of(falling, mouth, curved)) {
|
||||
SurfaceFootprintCompiler compiler = compiler(sampler);
|
||||
SurfaceFootprintCompiler.PreparedCourse prepared = compiler.prepare(course);
|
||||
for (int x = -128; x < 256; x += 32) {
|
||||
SurfaceBounds bounds = new SurfaceBounds(x, -80, x + 31, 80);
|
||||
assertEquals(compiler.compile(course, bounds), compiler.compile(prepared, bounds));
|
||||
}
|
||||
assertEquals(compiler.compile(course), compiler.compile(prepared, null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void subsequentPreparedWindowsDoNotResampleDistantCourseGeometry() {
|
||||
AtomicInteger distantSamples = new AtomicInteger();
|
||||
HydrologyTerrainSampler sampler = (int x, int z) -> {
|
||||
if (x >= 200) {
|
||||
distantSamples.incrementAndGet();
|
||||
}
|
||||
return HydrologyTerrainSample.openLand(90 + Math.abs(z) / 2, 0D, "land");
|
||||
};
|
||||
RiverCourse course = course(List.of(segment(1L, HydrologyFeatureType.SURFACE_POOL, 79, 79,
|
||||
points(0, 400, 79))));
|
||||
SurfaceFootprintCompiler compiler = compiler(sampler);
|
||||
SurfaceFootprintCompiler.PreparedCourse prepared = compiler.prepare(course);
|
||||
assertEquals(0, distantSamples.get());
|
||||
compiler.compile(prepared, new SurfaceBounds(0, -16, 31, 16));
|
||||
assertTrue(distantSamples.get() > 0);
|
||||
distantSamples.set(0);
|
||||
SurfaceBounds bounds = new SurfaceBounds(32, -16, 63, 16);
|
||||
SurfaceFootprint reused = compiler.compile(prepared, bounds);
|
||||
assertEquals(0, distantSamples.get());
|
||||
assertEquals(compiler.compile(course, bounds), reused);
|
||||
assertTrue(distantSamples.get() > 0);
|
||||
}
|
||||
|
||||
private static SurfaceLayerColumn column(SurfaceFootprint footprint, int x, int z) {
|
||||
for (SurfaceLayerColumn column : footprint.columns()) {
|
||||
if (column.x() == x && column.z() == z) {
|
||||
|
||||
+103
-5
@@ -22,6 +22,8 @@ import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
@@ -33,6 +35,8 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class EngineMantleCoverageTest {
|
||||
@@ -122,7 +126,7 @@ public class EngineMantleCoverageTest {
|
||||
Chunk neighbor = new Chunk(-2, 0);
|
||||
fixture.real().remove(neighbor);
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
when(fixture.mantle().hasFlag(-2, 0, MantleFlag.REAL)).thenAnswer(invocation -> {
|
||||
when(fixture.mantle().hasLoadedFlag(-2, 0, MantleFlag.REAL)).thenAnswer(invocation -> {
|
||||
if (reads.incrementAndGet() > 1) {
|
||||
fixture.real().add(neighbor);
|
||||
return true;
|
||||
@@ -181,6 +185,95 @@ public class EngineMantleCoverageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cleanupDefersUnloadedCoverageWithoutReadingFlags() {
|
||||
CoverageFixture fixture = fixture(1);
|
||||
fill(fixture, 2);
|
||||
when(fixture.mantle().isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
|
||||
List<Chunk> actual = new ArrayList<>();
|
||||
|
||||
fixture.engine().cleanupChunksCoveredBy(0, 0, true,
|
||||
(x, z) -> actual.add(new Chunk(x, z)));
|
||||
|
||||
assertTrue(actual.isEmpty());
|
||||
verify(fixture.mantle(), never()).hasLoadedFlag(anyInt(), anyInt(), any());
|
||||
verify(fixture.mantle(), never()).getChunk(anyInt(), anyInt());
|
||||
|
||||
when(fixture.mantle().isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
fixture.engine().cleanupChunksCoveredBy(0, 0, true,
|
||||
(x, z) -> actual.add(new Chunk(x, z)));
|
||||
assertEquals(9, actual.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cleanupSkipsTargetEvictedAfterCoverageWithoutLoadingIt() {
|
||||
CoverageFixture fixture = fixture(0);
|
||||
fill(fixture, 0);
|
||||
doReturn(false).when(fixture.mantle()).withLoadedChunk(eq(0), eq(0), any());
|
||||
List<Chunk> actual = new ArrayList<>();
|
||||
|
||||
fixture.engine().cleanupChunksCoveredBy(0, 0, true,
|
||||
(x, z) -> actual.add(new Chunk(x, z)));
|
||||
|
||||
assertTrue(actual.isEmpty());
|
||||
assertTrue(fixture.cleaned().isEmpty());
|
||||
verify(fixture.mantle(), never()).getChunk(anyInt(), anyInt());
|
||||
verify(fixture.mantle(), never()).hasFlag(anyInt(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cleanupSkipsHaloEvictedAfterResidencyCheck() {
|
||||
CoverageFixture fixture = fixture(1);
|
||||
fill(fixture, 2);
|
||||
when(fixture.mantle().hasLoadedFlag(anyInt(), anyInt(), eq(MantleFlag.REAL))).thenReturn(false);
|
||||
List<Chunk> actual = new ArrayList<>();
|
||||
|
||||
fixture.engine().cleanupChunksCoveredBy(0, 0, true,
|
||||
(x, z) -> actual.add(new Chunk(x, z)));
|
||||
|
||||
assertTrue(actual.isEmpty());
|
||||
verify(fixture.mantle(), never()).withLoadedChunk(anyInt(), anyInt(), any());
|
||||
verify(fixture.mantle(), never()).getChunk(anyInt(), anyInt());
|
||||
verify(fixture.mantle(), never()).hasFlag(anyInt(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void busyTargetCanBeCleanedOnRetryAndCallbackRunsOutsideGuard() {
|
||||
CoverageFixture fixture = fixture(0);
|
||||
fill(fixture, 0);
|
||||
AtomicBoolean busy = new AtomicBoolean(true);
|
||||
AtomicBoolean guarded = new AtomicBoolean();
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
when(chunk.use()).thenReturn(chunk);
|
||||
doAnswer(invocation -> {
|
||||
if (busy.getAndSet(false)) {
|
||||
return false;
|
||||
}
|
||||
guarded.set(true);
|
||||
try {
|
||||
Predicate<MantleChunk<Matter>> action = invocation.getArgument(2);
|
||||
boolean cleaned = action.test(chunk);
|
||||
if (cleaned) {
|
||||
fixture.cleaned().add(new Chunk(0, 0));
|
||||
}
|
||||
return cleaned;
|
||||
} finally {
|
||||
guarded.set(false);
|
||||
}
|
||||
}).when(fixture.mantle()).withLoadedChunk(eq(0), eq(0), any());
|
||||
List<Chunk> actual = new ArrayList<>();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
fixture.engine().cleanupChunksCoveredBy(0, 0, true, (x, z) -> {
|
||||
assertTrue("Callback must run after releasing region guard", !guarded.get());
|
||||
actual.add(new Chunk(x, z));
|
||||
});
|
||||
}
|
||||
assertEquals(List.of(new Chunk(0, 0)), actual);
|
||||
verify(fixture.mantle(), never()).getChunk(anyInt(), anyInt());
|
||||
verify(fixture.mantle(), never()).hasFlag(anyInt(), anyInt(), any());
|
||||
}
|
||||
|
||||
private static List<Chunk> fullScan(CoverageFixture fixture, int x, int z, int radius) {
|
||||
List<Chunk> result = new ArrayList<>();
|
||||
for (int offsetX = -radius; offsetX <= radius; offsetX++) {
|
||||
@@ -236,7 +329,8 @@ public class EngineMantleCoverageTest {
|
||||
doReturn(mantle).when(engine).getMantle();
|
||||
doReturn(radius).when(engine).getRadius();
|
||||
doReturn(0).when(engine).getRealRadius();
|
||||
when(mantle.hasFlag(anyInt(), anyInt(), any())).thenAnswer(invocation -> {
|
||||
when(mantle.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
when(mantle.hasLoadedFlag(anyInt(), anyInt(), any())).thenAnswer(invocation -> {
|
||||
Chunk position = new Chunk(invocation.getArgument(0), invocation.getArgument(1));
|
||||
MantleFlag flag = invocation.getArgument(2);
|
||||
if (flag == MantleFlag.REAL) {
|
||||
@@ -245,9 +339,13 @@ public class EngineMantleCoverageTest {
|
||||
}
|
||||
return cleaned.contains(position);
|
||||
});
|
||||
when(mantle.getChunk(anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
cleaned.add(new Chunk(invocation.getArgument(0), invocation.getArgument(1)));
|
||||
return chunk;
|
||||
when(mantle.withLoadedChunk(anyInt(), anyInt(), any())).thenAnswer(invocation -> {
|
||||
Predicate<MantleChunk<Matter>> action = invocation.getArgument(2);
|
||||
boolean result = action.test(chunk);
|
||||
if (result) {
|
||||
cleaned.add(new Chunk(invocation.getArgument(0), invocation.getArgument(1)));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
when(chunk.use()).thenReturn(chunk);
|
||||
doAnswer(invocation -> null).when(chunk).raiseFlagUnchecked(eq(MantleFlag.CLEANED), any());
|
||||
|
||||
+5
-6
@@ -120,20 +120,19 @@ public class IrisComplexHydrologyNoiseCacheTest {
|
||||
}
|
||||
|
||||
@Test(timeout = 10_000L)
|
||||
public void concurrentFirstDemandsShareOneReservation() throws Exception {
|
||||
public void concurrentCacheExpansionsShareOneReservation() throws Exception {
|
||||
try (Fixture fixture = new Fixture(1_024);
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2)) {
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
|
||||
HydrologyTileCache cache = fixture.planningCache(budget);
|
||||
CountDownLatch entered = new CountDownLatch(2);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
cache.setTerrainPreparation(() -> {
|
||||
Runnable prepare = () -> {
|
||||
entered.countDown();
|
||||
await(release);
|
||||
fixture.complex.expandHydrologyNoiseCaches(budget);
|
||||
});
|
||||
Future<?> first = callers.submit(() -> cache.get(new HydrologyTileKey(0, 0)));
|
||||
Future<?> second = callers.submit(() -> cache.get(new HydrologyTileKey(1, 0)));
|
||||
};
|
||||
Future<?> first = callers.submit(prepare);
|
||||
Future<?> second = callers.submit(prepare);
|
||||
assertTrue(entered.await(5L, TimeUnit.SECONDS));
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
release.countDown();
|
||||
|
||||
+7
-4
@@ -278,13 +278,15 @@ public class IrisEngineEntryHydrologyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactOriginChunkDemandsAllFourTilesConcurrently() throws Exception {
|
||||
public void exactOriginChunkDemandsAllFourTilesWithinTheRootBudget() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyPlannerSettings settings = mock(HydrologyPlannerSettings.class);
|
||||
when(settings.routing()).thenReturn(HydrologyPlannerSettings.defaults().routing());
|
||||
when(settings.publicationRadius()).thenReturn(64);
|
||||
when(planner.settings()).thenReturn(settings);
|
||||
CountDownLatch started = new CountDownLatch(4);
|
||||
int admitted = (int) Math.max(1L, Math.min(4L, Math.min(Runtime.getRuntime().availableProcessors(),
|
||||
Runtime.getRuntime().maxMemory() / (2L * 1024L * 1024L * 1024L))));
|
||||
CountDownLatch started = new CountDownLatch(admitted);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
Set<HydrologyTileKey> keys = ConcurrentHashMap.newKeySet();
|
||||
when(planner.plan(any())).thenAnswer(invocation -> {
|
||||
@@ -302,12 +304,13 @@ public class IrisEngineEntryHydrologyTest {
|
||||
Future<?> preparation = caller.submit(() -> cache.prepareChunkColumns(0, 0));
|
||||
try {
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
assertEquals(Set.of(new HydrologyTileKey(-1, -1), new HydrologyTileKey(0, -1),
|
||||
new HydrologyTileKey(-1, 0), new HydrologyTileKey(0, 0)), keys);
|
||||
assertEquals(admitted, keys.size());
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
preparation.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(Set.of(new HydrologyTileKey(-1, -1), new HydrologyTileKey(0, -1),
|
||||
new HydrologyTileKey(-1, 0), new HydrologyTileKey(0, 0)), keys);
|
||||
cache.prepareChunkColumns(0, 0);
|
||||
assertEquals(4, keys.size());
|
||||
cache.close();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package art.arcane.iris.generation.runtime;
|
||||
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter;
|
||||
import art.arcane.volmlib.util.matter.IrisMatter;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterBiomeInject;
|
||||
import art.arcane.volmlib.util.matter.slices.BiomeInjectMatter;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class IrisEngineMantleReadTest {
|
||||
@Test
|
||||
public void missingReadsAndRemovalsLeaveSectionEmpty() {
|
||||
MantleDataAdapter<Matter> adapter = IrisEngineMantle.createRuntimeDataAdapter(mock(IrisData.class));
|
||||
Matter section = new IrisMatter(16, 16, 16);
|
||||
|
||||
for (int y = 0; y < 16; y++) {
|
||||
assertNull(adapter.get(section, 0, y, 0, MatterBiomeInject.class));
|
||||
adapter.remove(section, 0, y, 0, MatterBiomeInject.class);
|
||||
}
|
||||
|
||||
assertTrue(section.getSliceMap().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void existingSlicesKeepValuesAndCoordinateRemoval() {
|
||||
MantleDataAdapter<Matter> adapter = IrisEngineMantle.createRuntimeDataAdapter(mock(IrisData.class));
|
||||
Matter section = new IrisMatter(16, 16, 16);
|
||||
BiomeInjectMatter slice = new BiomeInjectMatter(16, 16, 16);
|
||||
MatterBiomeInject value = BiomeInjectMatter.get("minecraft:plains");
|
||||
section.putSlice(MatterBiomeInject.class, slice);
|
||||
slice.set(0, 0, 0, value);
|
||||
slice.set(4, 4, 4, value);
|
||||
|
||||
assertSame(value, adapter.get(section, 4, 4, 4, MatterBiomeInject.class));
|
||||
adapter.remove(section, 0, 0, 0, MatterBiomeInject.class);
|
||||
|
||||
assertNull(adapter.get(section, 0, 0, 0, MatterBiomeInject.class));
|
||||
assertSame(value, adapter.get(section, 4, 4, 4, MatterBiomeInject.class));
|
||||
assertEquals(1, section.getSliceMap().size());
|
||||
}
|
||||
}
|
||||
@@ -241,6 +241,11 @@ public class ContentKeyValidatorTest {
|
||||
|
||||
private record FakeRegistries(List<String> blocks, List<String> items, List<String> entities,
|
||||
Map<String, List<NativeBlockProperty>> properties) implements PlatformRegistries {
|
||||
@Override
|
||||
public NativeBlockState decodeBlockState(String key) {
|
||||
return blockOrNull(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeBlockState block(String key) {
|
||||
return null;
|
||||
|
||||
@@ -209,6 +209,11 @@ public class SchemaBuilderIdentityTest {
|
||||
private static final List<NativeBlockProperty> LOG_PROPERTIES = List.of(
|
||||
new NativeBlockProperty("axis", "string", "y", List.of("x", "y", "z"), null));
|
||||
|
||||
@Override
|
||||
public NativeBlockState decodeBlockState(String key) {
|
||||
return blockOrNull(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeBlockState block(String key) {
|
||||
return null;
|
||||
|
||||
@@ -459,6 +459,11 @@ public class SchemaBuilderParityTest {
|
||||
}
|
||||
|
||||
private static final class FakeRegistries implements PlatformRegistries {
|
||||
@Override
|
||||
public NativeBlockState decodeBlockState(String key) {
|
||||
return blockOrNull(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeBlockState block(String key) {
|
||||
return null;
|
||||
|
||||
@@ -80,6 +80,11 @@ final class CompatFixtures {
|
||||
final Set<String> potionEffects = new HashSet<>(List.of("minecraft:speed", "minecraft:strength"));
|
||||
int blockLookups = 0;
|
||||
|
||||
@Override
|
||||
public NativeBlockState decodeBlockState(String key) {
|
||||
return blockOrNull(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeBlockState block(String key) {
|
||||
NativeBlockState state = blockOrNull(key, false);
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package art.arcane.iris.platform.bukkit;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.generation.block.IrisCustomData;
|
||||
import art.arcane.iris.integration.ExternalDataSVC;
|
||||
import art.arcane.iris.integration.Identifier;
|
||||
import art.arcane.iris.platform.registry.RegistryUtil;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.testsupport.BukkitTestServer;
|
||||
import art.arcane.iris.world.storage.matter.IrisMatterSupport;
|
||||
import art.arcane.iris.world.storage.matter.PreObjectMatterCell;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
import art.arcane.volmlib.util.matter.IrisMatter;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.type.Leaves;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class BukkitPersistedBlockStateTest {
|
||||
@BeforeClass
|
||||
public static void installServer() {
|
||||
BukkitTestServer.install();
|
||||
try (MockedStatic<RegistryUtil> registry = mockStatic(RegistryUtil.class)) {
|
||||
registry.when(() -> RegistryUtil.find(Material.class, "grass", "short_grass"))
|
||||
.thenReturn(Material.SHORT_GRASS);
|
||||
BukkitBlockResolution.getAir();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decodingPreservesExplicitPropertiesDespiteGenerationLeafPolicy() {
|
||||
BukkitRegistries registries = new BukkitRegistries();
|
||||
IrisSettings configuration = new IrisSettings();
|
||||
configuration.getGenerator().setPreventLeafDecay(true);
|
||||
String key = "minecraft:oak_leaves[distance=2,persistent=false,waterlogged=true]";
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS);
|
||||
MockedStatic<IrisSettings> settings = mockStatic(IrisSettings.class)) {
|
||||
bukkit.when(() -> Bukkit.createBlockData(anyString()))
|
||||
.thenAnswer(invocation -> parsed(invocation.getArgument(0)));
|
||||
settings.when(IrisSettings::get).thenReturn(configuration);
|
||||
|
||||
assertEquals(key.replace("persistent=false", "persistent=true"), registries.blockOrNull(key).key());
|
||||
NativeBlockState decoded = registries.decodeBlockState(key);
|
||||
assertEquals(key, decoded.key());
|
||||
assertSame(decoded, registries.decodeBlockState(key));
|
||||
assertEquals(key, ((BlockData) decoded.nativeHandle()).getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bothMatterSlicesRoundTripFullNativeProperties() throws IOException {
|
||||
BukkitRegistries registries = new BukkitRegistries();
|
||||
IrisPlatform previous = IrisPlatforms.getOrNull();
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
IrisPlatforms.unbind();
|
||||
IrisPlatforms.bind(platform);
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) {
|
||||
bukkit.when(() -> Bukkit.createBlockData(anyString()))
|
||||
.thenAnswer(invocation -> parsed(invocation.getArgument(0)));
|
||||
IrisMatterSupport.ensureRegistered();
|
||||
Matter matter = new IrisMatter(16, 16, 16);
|
||||
String[] keys = {
|
||||
"minecraft:oak_leaves[distance=3,persistent=false,waterlogged=false]",
|
||||
"minecraft:oak_leaves[distance=6,persistent=true,waterlogged=true]",
|
||||
"minecraft:oak_stairs[facing=east,half=top,shape=inner_left,waterlogged=true]"
|
||||
};
|
||||
for (int i = 0; i < keys.length; i++) {
|
||||
NativeBlockState block = BukkitBlockState.of(parsed(keys[i]));
|
||||
matter.<NativeBlockState>slice(NativeBlockState.class).set(i, 2, 3, block);
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class)
|
||||
.set(i, 2, 3, PreObjectMatterCell.block(block));
|
||||
}
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
matter.write(bytes);
|
||||
Matter restored = Matter.read(new ByteArrayInputStream(bytes.toByteArray()));
|
||||
for (int i = 0; i < keys.length; i++) {
|
||||
assertEquals(keys[i], restored.<NativeBlockState>getSlice(NativeBlockState.class).get(i, 2, 3).key());
|
||||
assertEquals(keys[i], restored.<PreObjectMatterCell>getSlice(PreObjectMatterCell.class)
|
||||
.get(i, 2, 3).block().key());
|
||||
}
|
||||
} finally {
|
||||
IrisPlatforms.unbind();
|
||||
if (previous != null) {
|
||||
IrisPlatforms.bind(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decodingRetainsCustomProviderIdentityAndRegisteredStates() {
|
||||
BukkitRegistries registries = new BukkitRegistries();
|
||||
String key = "test:persisted_leaves[distance=2,persistent=false]";
|
||||
BlockData carrier = parsed("minecraft:oak_leaves[distance=2,persistent=false,waterlogged=false]");
|
||||
IrisCustomData custom = IrisCustomData.of(carrier, Identifier.fromString(key));
|
||||
ExternalDataSVC provider = mock(ExternalDataSVC.class);
|
||||
when(provider.getBlockData(Identifier.fromString(key))).thenReturn(Optional.of(custom));
|
||||
try (MockedStatic<IrisServices> services = mockStatic(IrisServices.class)) {
|
||||
services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(provider);
|
||||
NativeBlockState decoded = registries.decodeBlockState(key);
|
||||
assertEquals(key, decoded.key());
|
||||
assertEquals(key, decoded.deferredPlacementKey());
|
||||
assertSame(custom, decoded.nativeHandle());
|
||||
}
|
||||
BukkitBlockResolution.registerCustomBlockData("test", "registered_persisted_leaves", carrier);
|
||||
assertSame(carrier, registries.decodeBlockState("test:registered_persisted_leaves").nativeHandle());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidPersistedPropertiesAreNotSilentlyStripped() {
|
||||
String key = "minecraft:oak_leaves[distance=invalid,persistent=false]";
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) {
|
||||
bukkit.when(() -> Bukkit.createBlockData(key)).thenThrow(new IllegalArgumentException("invalid distance"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new BukkitRegistries().decodeBlockState(key));
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockData parsed(String key) {
|
||||
if (!key.startsWith("minecraft:oak_leaves[")) {
|
||||
return BukkitTestServer.blockData(key);
|
||||
}
|
||||
Leaves leaves = mock(Leaves.class);
|
||||
AtomicBoolean persistent = new AtomicBoolean(key.contains("persistent=true"));
|
||||
doAnswer(invocation -> key.replaceFirst("persistent=(true|false)", "persistent=" + persistent.get()))
|
||||
.when(leaves).getAsString();
|
||||
doAnswer(invocation -> key.replaceFirst("persistent=(true|false)", "persistent=" + persistent.get()))
|
||||
.when(leaves).getAsString(anyBoolean());
|
||||
doAnswer(invocation -> {
|
||||
persistent.set(invocation.getArgument(0));
|
||||
return null;
|
||||
}).when(leaves).setPersistent(anyBoolean());
|
||||
doReturn(Material.OAK_LEAVES).when(leaves).getMaterial();
|
||||
return leaves;
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,11 @@ public final class CompatTestPlatform {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeBlockState decodeBlockState(String key) {
|
||||
return blockOrNull(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeBlockState block(String key) {
|
||||
NativeBlockState state = blockOrNull(key, false);
|
||||
|
||||
@@ -25,6 +25,11 @@ import javax.management.MBeanServer;
|
||||
import javax.management.ObjectName;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@@ -40,15 +45,147 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
public class MantleHeapPressureTest {
|
||||
private static final MantleHeapPressure.PanicGcPolicy POLICY =
|
||||
new MantleHeapPressure.PanicGcPolicy(10_000L, 60_000L, 60_000L, 240_000L);
|
||||
new MantleHeapPressure.PanicGcPolicy(250L, 5_000L, 10_000L, 60_000L, 60_000L, 240_000L);
|
||||
|
||||
@Test
|
||||
public void engagedAdmissionStartsReclaimAfterUsageDropsBeforeFirstRequest() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate(
|
||||
0.92D, 0.82D, 60_000L, fixture.clock::get, fixture.reclaimer::resetEpisode);
|
||||
|
||||
assertEquals(true, gate.update(0.95D));
|
||||
fixture.clock.set(100L);
|
||||
double fraction = 0.85D;
|
||||
fixture.reclaimer.request(fraction, gate.update(fraction));
|
||||
fixture.clock.set(349L);
|
||||
fixture.reclaimer.request(fraction, gate.update(fraction));
|
||||
assertEquals(0, fixture.explicitCalls.get());
|
||||
fixture.clock.set(350L);
|
||||
fixture.reclaimer.request(fraction, gate.update(fraction));
|
||||
assertEquals(1, fixture.explicitCalls.get());
|
||||
assertEquals(0, fixture.diagnosticCalls.get());
|
||||
|
||||
fixture.clock.set(10_349L);
|
||||
fixture.reclaimer.request(fraction, gate.update(fraction));
|
||||
assertEquals(0, fixture.diagnosticCalls.get());
|
||||
fixture.clock.set(10_350L);
|
||||
fixture.reclaimer.request(fraction, gate.update(fraction));
|
||||
assertEquals(1, fixture.diagnosticCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ungatedUsageBelowHighWaterDoesNotStartReclaim() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate(
|
||||
0.92D, 0.82D, 60_000L, fixture.clock::get, fixture.reclaimer::resetEpisode);
|
||||
double fraction = 0.85D;
|
||||
|
||||
fixture.reclaimer.request(fraction, gate.update(fraction));
|
||||
fixture.clock.set(120_000L);
|
||||
fixture.reclaimer.request(fraction, gate.update(fraction));
|
||||
|
||||
assertEquals(0, fixture.explicitCalls.get());
|
||||
assertEquals(0, fixture.diagnosticCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recoveredSpikeCancelsPendingExplicitGc() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate(
|
||||
0.92D, 0.82D, 60_000L, fixture.clock::get, fixture.reclaimer::resetEpisode);
|
||||
|
||||
assertEquals(true, gate.update(0.95D));
|
||||
fixture.reclaimer.request(0.95D, false);
|
||||
fixture.clock.set(100L);
|
||||
assertEquals(false, gate.update(0.81D));
|
||||
fixture.clock.set(250L);
|
||||
fixture.reclaimer.request(0.90D, false);
|
||||
assertEquals(0, fixture.explicitCalls.get());
|
||||
assertEquals(0, fixture.diagnosticCalls.get());
|
||||
|
||||
fixture.reclaimer.request(0.95D, false);
|
||||
fixture.clock.set(300L);
|
||||
fixture.reclaimer.request(0.82D, false);
|
||||
fixture.clock.set(500L);
|
||||
fixture.reclaimer.request(0.90D, false);
|
||||
assertEquals(0, fixture.explicitCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitGcWaitsForGraceWhileHysteresisPressureStillReclaims() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
|
||||
fixture.reclaimer.request(0.95D, false);
|
||||
fixture.clock.set(249L);
|
||||
fixture.reclaimer.request(0.95D, false);
|
||||
assertEquals(0, fixture.explicitCalls.get());
|
||||
fixture.clock.set(250L);
|
||||
fixture.reclaimer.request(0.85D, false);
|
||||
fixture.reclaimer.request(0.85D, false);
|
||||
assertEquals(1, fixture.explicitCalls.get());
|
||||
assertEquals(0, fixture.diagnosticCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recoveredEpisodesShareExplicitCooldownAndEscalateFromActualAttempt() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
fixture.beginExplicit(0.95D);
|
||||
fixture.reclaimer.resetEpisode();
|
||||
fixture.clock.set(1_000L);
|
||||
fixture.reclaimer.request(0.95D, false);
|
||||
fixture.clock.set(1_250L);
|
||||
fixture.reclaimer.request(0.95D, false);
|
||||
fixture.clock.set(5_249L);
|
||||
fixture.reclaimer.request(0.95D, false);
|
||||
assertEquals(1, fixture.explicitCalls.get());
|
||||
fixture.clock.set(5_250L);
|
||||
fixture.reclaimer.request(0.95D, false);
|
||||
assertEquals(2, fixture.explicitCalls.get());
|
||||
fixture.clock.set(15_249L);
|
||||
fixture.reclaimer.request(0.95D, false);
|
||||
assertEquals(0, fixture.diagnosticCalls.get());
|
||||
fixture.clock.set(15_250L);
|
||||
fixture.reclaimer.request(0.95D, false);
|
||||
assertEquals(1, fixture.diagnosticCalls.get());
|
||||
}
|
||||
|
||||
@Test(timeout = 10_000L)
|
||||
public void concurrentRequestersShareOneExplicitAttempt() throws Exception {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
fixture.reclaimer.request(0.95D, false);
|
||||
fixture.clock.set(250L);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
|
||||
List<Future<?>> requests = new ArrayList<>();
|
||||
for (int index = 0; index < 2; index++) {
|
||||
requests.add(executor.submit(() -> {
|
||||
ready.countDown();
|
||||
start.await();
|
||||
fixture.reclaimer.request(0.95D, false);
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
try {
|
||||
assertEquals(true, ready.await(5L, TimeUnit.SECONDS));
|
||||
} finally {
|
||||
start.countDown();
|
||||
}
|
||||
for (Future<?> request : requests) {
|
||||
request.get(5L, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
assertEquals(1, fixture.explicitCalls.get());
|
||||
assertEquals(0, fixture.diagnosticCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalPressureNeverRequestsEitherGcPath() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.reclaimer.request(0.91D, false);
|
||||
fixture.clock.addAndGet(120_000L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.reclaimer.request(0.91D, false);
|
||||
|
||||
assertEquals(0, fixture.explicitCalls.get());
|
||||
assertEquals(0, fixture.diagnosticCalls.get());
|
||||
@@ -58,13 +195,13 @@ public class MantleHeapPressureTest {
|
||||
public void sustainedPanicInvokesDiagnosticOnlyAfterNormalReclaimGrace() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(9_999L);
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(10_000L);
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(69_999L);
|
||||
fixture.reclaimer.request(0.99D);
|
||||
fixture.beginExplicit(0.97D);
|
||||
fixture.clock.set(10_249L);
|
||||
fixture.reclaimer.request(0.97D, false);
|
||||
fixture.clock.set(10_250L);
|
||||
fixture.reclaimer.request(0.97D, false);
|
||||
fixture.clock.set(70_249L);
|
||||
fixture.reclaimer.request(0.99D, false);
|
||||
|
||||
assertEquals(1, fixture.explicitCalls.get());
|
||||
assertEquals(1, fixture.diagnosticCalls.get());
|
||||
@@ -75,16 +212,16 @@ public class MantleHeapPressureTest {
|
||||
public void recoveredEpisodeResetsButDiagnosticCooldownStillApplies() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(10_000L);
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.beginExplicit(0.97D);
|
||||
fixture.clock.set(10_250L);
|
||||
fixture.reclaimer.request(0.97D, false);
|
||||
fixture.reclaimer.resetEpisode();
|
||||
fixture.clock.set(20_000L);
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(30_000L);
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(70_000L);
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.beginExplicit(0.97D);
|
||||
fixture.clock.set(30_250L);
|
||||
fixture.reclaimer.request(0.97D, false);
|
||||
fixture.clock.set(70_250L);
|
||||
fixture.reclaimer.request(0.97D, false);
|
||||
|
||||
assertEquals(2, fixture.explicitCalls.get());
|
||||
assertEquals(2, fixture.diagnosticCalls.get());
|
||||
@@ -94,11 +231,11 @@ public class MantleHeapPressureTest {
|
||||
public void highWaterEpisodeDiagnosesAfterNormalReclaimLeavesHeapBelowHighWater() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
|
||||
fixture.reclaimer.request(0.93D);
|
||||
fixture.clock.set(9_999L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.clock.set(10_000L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.beginExplicit(0.93D);
|
||||
fixture.clock.set(10_249L);
|
||||
fixture.reclaimer.request(0.91D, false);
|
||||
fixture.clock.set(10_250L);
|
||||
fixture.reclaimer.request(0.91D, false);
|
||||
|
||||
assertEquals(1, fixture.explicitCalls.get());
|
||||
assertEquals(1, fixture.diagnosticCalls.get());
|
||||
@@ -109,13 +246,13 @@ public class MantleHeapPressureTest {
|
||||
public void successfulDiagnosticRetriesAfterCooldownWhenPressureRemainsHigh() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(10_000L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.clock.set(69_999L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.clock.set(70_000L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.beginExplicit(0.97D);
|
||||
fixture.clock.set(10_250L);
|
||||
fixture.reclaimer.request(0.91D, false);
|
||||
fixture.clock.set(70_249L);
|
||||
fixture.reclaimer.request(0.91D, false);
|
||||
fixture.clock.set(70_250L);
|
||||
fixture.reclaimer.request(0.91D, false);
|
||||
|
||||
assertEquals(1, fixture.explicitCalls.get());
|
||||
assertEquals(2, fixture.diagnosticCalls.get());
|
||||
@@ -126,13 +263,13 @@ public class MantleHeapPressureTest {
|
||||
public void failedDiagnosticRetriesAfterBackoffWithinSameEpisode() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(1);
|
||||
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(10_000L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.clock.set(69_999L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.clock.set(70_000L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.beginExplicit(0.97D);
|
||||
fixture.clock.set(10_250L);
|
||||
fixture.reclaimer.request(0.91D, false);
|
||||
fixture.clock.set(70_249L);
|
||||
fixture.reclaimer.request(0.91D, false);
|
||||
fixture.clock.set(70_250L);
|
||||
fixture.reclaimer.request(0.91D, false);
|
||||
|
||||
assertEquals(1, fixture.explicitCalls.get());
|
||||
assertEquals(2, fixture.diagnosticCalls.get());
|
||||
@@ -151,6 +288,7 @@ public class MantleHeapPressureTest {
|
||||
releases::incrementAndGet);
|
||||
|
||||
assertEquals(true, gate.update(0.93D));
|
||||
clock.set(30_000L);
|
||||
assertEquals(true, gate.update(0.89D));
|
||||
clock.set(60_999L);
|
||||
assertEquals(true, gate.update(0.89D));
|
||||
@@ -161,7 +299,7 @@ public class MantleHeapPressureTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renewedHighPressureRestartsHysteresisAndCanReengageAfterRelease() {
|
||||
public void renewedHighPressureKeepsEpisodeDeadlineAndCanReengageAfterRelease() {
|
||||
AtomicLong clock = new AtomicLong(5_000L);
|
||||
AtomicInteger releases = new AtomicInteger();
|
||||
MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate(
|
||||
@@ -173,15 +311,64 @@ public class MantleHeapPressureTest {
|
||||
|
||||
assertEquals(true, gate.update(0.93D));
|
||||
assertEquals(true, gate.update(0.88D));
|
||||
clock.set(35_000L);
|
||||
assertEquals(true, gate.update(0.94D));
|
||||
assertEquals(true, gate.update(0.88D));
|
||||
clock.set(64_999L);
|
||||
assertEquals(true, gate.update(0.93D));
|
||||
assertEquals(true, gate.update(0.88D));
|
||||
clock.set(124_998L);
|
||||
assertEquals(true, gate.update(0.88D));
|
||||
clock.set(124_999L);
|
||||
clock.set(65_000L);
|
||||
assertEquals(true, gate.update(0.92D));
|
||||
assertEquals(0, releases.get());
|
||||
assertEquals(false, gate.update(0.88D));
|
||||
assertEquals(1, releases.get());
|
||||
|
||||
clock.set(70_000L);
|
||||
assertEquals(true, gate.update(0.92D));
|
||||
clock.set(129_999L);
|
||||
assertEquals(true, gate.update(0.88D));
|
||||
clock.set(130_000L);
|
||||
assertEquals(false, gate.update(0.88D));
|
||||
assertEquals(2, releases.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void prolongedHighPressureWaitsForSubHighSampleAfterEpisodeDeadline() {
|
||||
AtomicLong clock = new AtomicLong(5_000L);
|
||||
AtomicInteger releases = new AtomicInteger();
|
||||
MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate(
|
||||
0.92D,
|
||||
0.82D,
|
||||
60_000L,
|
||||
clock::get,
|
||||
releases::incrementAndGet);
|
||||
|
||||
assertEquals(true, gate.update(0.95D));
|
||||
clock.set(65_000L);
|
||||
assertEquals(true, gate.update(0.92D));
|
||||
clock.set(125_000L);
|
||||
assertEquals(true, gate.update(0.97D));
|
||||
assertEquals(0, releases.get());
|
||||
assertEquals(false, gate.update(0.91D));
|
||||
assertEquals(1, releases.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void zeroHysteresisStillBlocksHighWaterSamples() {
|
||||
AtomicLong clock = new AtomicLong();
|
||||
AtomicInteger releases = new AtomicInteger();
|
||||
MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate(
|
||||
0.92D,
|
||||
0.82D,
|
||||
0L,
|
||||
clock::get,
|
||||
releases::incrementAndGet);
|
||||
|
||||
assertEquals(true, gate.update(0.92D));
|
||||
assertEquals(true, gate.update(0.95D));
|
||||
assertEquals(0, releases.get());
|
||||
assertEquals(false, gate.update(0.919D));
|
||||
assertEquals(1, releases.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -236,6 +423,12 @@ public class MantleHeapPressureTest {
|
||||
private final List<Throwable> failures = new ArrayList<>();
|
||||
private final MantleHeapPressure.PanicGcReclaimer reclaimer;
|
||||
|
||||
private void beginExplicit(double fraction) {
|
||||
reclaimer.request(fraction, false);
|
||||
clock.addAndGet(POLICY.explicitGraceMs());
|
||||
reclaimer.request(fraction, false);
|
||||
}
|
||||
|
||||
private ReclaimerFixture(int failuresBeforeSuccess) {
|
||||
MantleHeapPressure.PanicGcActions actions = new MantleHeapPressure.PanicGcActions(
|
||||
clock::get,
|
||||
|
||||
@@ -5,6 +5,9 @@ import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@@ -51,7 +54,7 @@ public class PregenMantleBackpressureTest {
|
||||
backpressure(() -> mantle, 4, 1_000L, () -> {
|
||||
}).enforceMantleBudget();
|
||||
|
||||
verify(mantle, never()).trim(0L, 0);
|
||||
verify(mantle, never()).trim(0L);
|
||||
verify(mantle, never()).unloadTectonicPlate(0);
|
||||
}
|
||||
|
||||
@@ -63,7 +66,7 @@ public class PregenMantleBackpressureTest {
|
||||
backpressure(() -> mantle, 4, 1_000L, () -> {
|
||||
}).enforceMantleBudget();
|
||||
|
||||
verify(mantle, times(1)).trim(0L, 0);
|
||||
verify(mantle, times(1)).trim(0L);
|
||||
verify(mantle, times(1)).unloadTectonicPlate(0);
|
||||
}
|
||||
|
||||
@@ -71,7 +74,7 @@ public class PregenMantleBackpressureTest {
|
||||
public void aTrimFailureEndsTheWaitInsteadOfSpinning() {
|
||||
Mantle mantle = mock(Mantle.class);
|
||||
when(mantle.getLoadedRegionCount()).thenReturn(20);
|
||||
doThrow(new IllegalStateException("mantle is gone")).when(mantle).trim(0L, 0);
|
||||
doThrow(new IllegalStateException("mantle is gone")).when(mantle).trim(0L);
|
||||
AtomicInteger timeouts = new AtomicInteger();
|
||||
|
||||
backpressure(() -> mantle, 4, 1_000L, timeouts::incrementAndGet).enforceMantleBudget();
|
||||
@@ -101,7 +104,7 @@ public class PregenMantleBackpressureTest {
|
||||
|
||||
backpressure.enforceMantleBudget();
|
||||
|
||||
verify(mantle, never()).trim(0L, 0);
|
||||
verify(mantle, never()).trim(0L);
|
||||
verify(mantle, never()).unloadTectonicPlate(0);
|
||||
assertEquals(0, timeouts.get());
|
||||
}
|
||||
@@ -140,6 +143,95 @@ public class PregenMantleBackpressureTest {
|
||||
assertFalse(waiter.isAlive());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void heapPressurePersistsIdlePlatesBeforeGcEvenBelowOrWithoutACountCap() {
|
||||
for (int cap : new int[]{0, 9, 16, 96}) {
|
||||
Mantle mantle = mock(Mantle.class);
|
||||
when(mantle.getLoadedRegionCount()).thenReturn(9);
|
||||
List<String> actions = new ArrayList<>();
|
||||
AtomicBoolean pressured = new AtomicBoolean(true);
|
||||
AtomicInteger timeouts = new AtomicInteger();
|
||||
when(mantle.saveOldestIdleTectonicPlate()).thenAnswer(invocation -> {
|
||||
actions.add("persist-and-unload");
|
||||
return true;
|
||||
});
|
||||
|
||||
backpressure(() -> mantle, cap, 1_000L, timeouts::incrementAndGet)
|
||||
.awaitHeapHeadroom(pressured::get, () -> {
|
||||
actions.add("gc");
|
||||
pressured.set(false);
|
||||
});
|
||||
|
||||
assertEquals(List.of("persist-and-unload", "gc"), actions);
|
||||
assertEquals(0, timeouts.get());
|
||||
verify(mantle, never()).trim(0L, 0);
|
||||
verify(mantle, never()).trim(0L);
|
||||
verify(mantle, never()).unloadTectonicPlate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void heapHeadroomDoesNotEvictRecentlyUsedPlatesWithoutPressure() {
|
||||
Mantle mantle = mock(Mantle.class);
|
||||
AtomicInteger collections = new AtomicInteger();
|
||||
|
||||
backpressure(() -> mantle, 16, 1_000L, () -> {
|
||||
}).awaitHeapHeadroom(() -> false, collections::incrementAndGet);
|
||||
|
||||
verify(mantle, never()).trim(0L);
|
||||
verify(mantle, never()).unloadTectonicPlate(0);
|
||||
verify(mantle, never()).saveOldestIdleTectonicPlate();
|
||||
assertEquals(0, collections.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pinnedPlatesStillRespectThePressureWaitDeadline() {
|
||||
Mantle mantle = mock(Mantle.class);
|
||||
when(mantle.getLoadedRegionCount()).thenReturn(9);
|
||||
when(mantle.saveOldestIdleTectonicPlate()).thenReturn(false);
|
||||
AtomicInteger collections = new AtomicInteger();
|
||||
AtomicInteger timeouts = new AtomicInteger();
|
||||
|
||||
backpressure(() -> mantle, 16, 0L, timeouts::incrementAndGet)
|
||||
.awaitHeapHeadroom(() -> true, collections::incrementAndGet);
|
||||
|
||||
verify(mantle, times(1)).saveOldestIdleTectonicPlate();
|
||||
verify(mantle, never()).unloadTectonicPlate(0);
|
||||
assertEquals(1, collections.get());
|
||||
assertEquals(1, timeouts.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedPressureEvictionStillAttemptsGcAndRespectsTheDeadline() {
|
||||
Mantle mantle = mock(Mantle.class);
|
||||
doThrow(new IllegalStateException("plate write failed")).when(mantle).saveOldestIdleTectonicPlate();
|
||||
AtomicInteger collections = new AtomicInteger();
|
||||
AtomicInteger timeouts = new AtomicInteger();
|
||||
|
||||
backpressure(() -> mantle, 16, 0L, timeouts::incrementAndGet)
|
||||
.awaitHeapHeadroom(() -> true, collections::incrementAndGet);
|
||||
|
||||
assertEquals(1, collections.get());
|
||||
assertEquals(1, timeouts.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancelledHeapPressureDoesNotEvictOrCollect() {
|
||||
Mantle mantle = mock(Mantle.class);
|
||||
AtomicInteger collections = new AtomicInteger();
|
||||
AtomicInteger timeouts = new AtomicInteger();
|
||||
PregenMantleBackpressure backpressure = new PregenMantleBackpressure(
|
||||
() -> mantle, 16, 5, 0L, timeouts::incrementAndGet, () -> "cancelled", () -> true);
|
||||
|
||||
backpressure.awaitHeapHeadroom(() -> true, collections::incrementAndGet);
|
||||
|
||||
verify(mantle, never()).trim(0L);
|
||||
verify(mantle, never()).unloadTectonicPlate(0);
|
||||
verify(mantle, never()).saveOldestIdleTectonicPlate();
|
||||
assertEquals(0, collections.get());
|
||||
assertEquals(0, timeouts.get());
|
||||
}
|
||||
|
||||
private static PregenMantleBackpressure backpressure(
|
||||
Supplier<Mantle> mantleSupplier,
|
||||
int maxResidentTectonicPlates,
|
||||
|
||||
@@ -36,7 +36,10 @@ public class PregenWaitPolicyContractTest {
|
||||
|
||||
assertTrue(backpressure.contains("progressed.await(waitMs, TimeUnit.MILLISECONDS)"));
|
||||
assertTrue(method(backpressure, "public void enforceMantleBudget()").contains("awaitProgress()"));
|
||||
assertTrue(method(backpressure, "public void awaitHeapHeadroom()").contains("awaitProgress()"));
|
||||
assertTrue(method(backpressure, "public void awaitHeapHeadroom()")
|
||||
.contains("awaitHeapHeadroom(MantleHeapPressure::overHighWater, MantleHeapPressure::requestPanicReclaim)"));
|
||||
assertTrue(method(backpressure, "void awaitHeapHeadroom(BooleanSupplier heapPressure, Runnable panicReclaim)")
|
||||
.contains("awaitProgress()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -617,6 +617,11 @@ public final class StubPlatform implements IrisPlatform {
|
||||
}
|
||||
|
||||
private static final class StubRegistries implements PlatformRegistries {
|
||||
@Override
|
||||
public NativeBlockState decodeBlockState(String key) {
|
||||
return blockOrNull(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeBlockState block(String key) {
|
||||
return StubBlockState.of(key);
|
||||
|
||||
@@ -47,6 +47,8 @@ public interface PlatformRegistries {
|
||||
);
|
||||
}
|
||||
|
||||
NativeBlockState decodeBlockState(String key);
|
||||
|
||||
/**
|
||||
* Resolves a block key through the platform's compatibility layer, which rewrites keys that moved between
|
||||
* Minecraft versions and consults registered custom-content providers. An unresolvable key is reported and
|
||||
|
||||
Reference in New Issue
Block a user