This commit is contained in:
Brian Neumann-Fopiano
2026-09-21 10:04:22 -04:00
parent 8e3ef1b651
commit 197d2c2461
73 changed files with 4586 additions and 424 deletions
@@ -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);
@@ -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]");