mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +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]");
|
||||
|
||||
Reference in New Issue
Block a user