NRF SDK 0.9

Creating a mod

Preview, confirmation and operation tracking

Build a responsive interface that waits without losing the plan and never replays an uncertain command.

Separate calculation, presentation and commands

Prerequisites: a service receiving a SignalActionRequest and a bounded position calculation. This guide builds a lifecycle in which the player sees a plan, confirms it, then receives the actual command outcome. Check the source, worldId, generation and local panel state. Different input, source or session immediately invalidates earlier confirmation.

An already received request must not apply a new plan without confirmation. Network copies, positions and tickets may remain in local state; ToolContext and objects capturing it must not outlive the callback. Use the next event or onTick’s new context to continue.

Tool stateAllowed action
Calculation requestedRead the network once, compute a bounded plan and retain copied values. A ticket never obliges the tool to build.
Presentation pendingRetry the read or presentation on a later onTick. Keep Apply disabled and never submit a command in place of a click.
Preview publishedRenew the same positions without capturing the whole network each tick. Before a build command, prepare, read again and compare.
Command submittedRetain the ticket and prevent a second submission. While the outcome is uncertain, repeat only status polling.
Panel closedRevoke the preview and local clicks; keep tracking an already submitted command. Closing does not cancel construction.

Handle a temporary refusal

ToolOperationException.isBusy means the operation cannot be served right now. It is not a permanent model defect. A read, showSignalPreview, clearSignalPreview or showPanel can be retried in a later callback, at most once per tick. Keep a flag for remaining work and return; do not add a waiting loop, blocking delay or unbounded retry queue.

A refused publication does not renew the old preview. A refused clear does not confirm removal: first revoke local permission to build and retain pending cleanup. A refused panel does not authorize a click from the old panel. Other errors require diagnosis and abandoning the stale plan; do not classify every exception as Busy. Even log can fail temporarily: diagnostics must not alter operation state or trigger another diagnostic loop.

An example without construction

PreviewTool.kt — preview and presentation recovery
package wiki.preview

import nimby.*

// Graphical markers only, not a route planner or a construction command.
fun previewPositions(network: ToolNetwork, sourceId: Long, count: Int): List<SignalPosition> {
    require(count in 1..64)
    val source = requireNotNull(network.topology().signal(sourceId)) { "Source or track unavailable." }
    return (1..count).map { source.placementAt(source.track, it.toDouble() / (count + 1)) }
}

// Test seam around public SDK calls, recreated for each callback, never retained.
interface PreviewPort {
    val worldId: String
    val generation: Long
    fun network(): ToolNetwork
    fun clear()
    fun show(request: SignalActionRequest, positions: List<SignalPosition>)
    fun panel(request: SignalActionRequest, message: String, count: Int, closed: Boolean)
}

class PreviewSession {
    private var request: SignalActionRequest? = null
    private var count = 3
    private var positions: List<SignalPosition>? = null
    private var clearRequested = false
    private var calculationRequested = false
    private var closed = false
    private var panelDirty = false
    private var message = "Choose the number of markers."
    private var idleMessage = message

    fun event(port: PreviewPort, next: SignalActionRequest) {
        if (next.worldId != port.worldId || next.generation != port.generation) return
        request = next
        positions = null // Revoke locally BEFORE calls that may be refused.
        calculationRequested = false
        clearRequested = true
        closed = next.action == "close"
        if (next.action == "count") next.value?.takeIf { it in 1..64 }?.let { count = it }
        calculationRequested = next.action == "show"
        message = if (closed) "" else "Preview hidden."
        idleMessage = message
        panelDirty = true
        tick(port)
    }

    fun tick(port: PreviewPort) {
        val current = request ?: return
        if (current.worldId != port.worldId || current.generation != port.generation) {
            stop()
            return
        }
        try {
            if (clearRequested) {
                port.clear(); clearRequested = false
                if (!calculationRequested) setMessage(idleMessage)
            }
            if (calculationRequested) {
                val snapshot = port.network()
                check(snapshot.worldId == current.worldId && snapshot.generation == current.generation)
                positions = previewPositions(snapshot, current.signalId, count)
                calculationRequested = false
            }
            positions?.let {
                port.show(current, it) // Renew using copied positions, no new capture.
                setMessage("Showing ${it.size} temporary markers.")
            }
        } catch (error: ToolOperationException) {
            if (error.isBusy) setMessage("Temporarily busy; waiting to display the preview.")
            else abandon()
        } catch (error: Exception) { abandon() }
        if (panelDirty) {
            try {
                port.panel(current, message, count, closed)
                panelDirty = false
            } catch (error: ToolOperationException) {
                if (!error.isBusy) stop()
            } catch (error: Exception) { stop() }
        }
    }

    private fun setMessage(value: String) {
        if (message != value) { message = value; panelDirty = true }
    }
    private fun abandon() {
        positions = null
        calculationRequested = false
        clearRequested = true
        idleMessage = "Preview unavailable; request a new preview."
        setMessage(idleMessage)
    }
    fun stop() {
        request = null; positions = null
        calculationRequested = false; clearRequested = false; panelDirty = false
    }
}

private fun ToolContext.previewPort() = object : PreviewPort {
    override val worldId get() = this@previewPort.worldId
    override val generation get() = this@previewPort.generation
    override fun network() = this@previewPort.network()
    override fun clear() = clearSignalPreview()
    override fun show(request: SignalActionRequest, positions: List<SignalPosition>) = showSignalPreview(request, positions)
    override fun panel(request: SignalActionRequest, message: String, count: Int, closed: Boolean) {
        if (closed) showPanel(request, "", listOf(ToolButton(request.originAction, "Open preview tool")))
        else showPanel(request, message,
            listOf(ToolButton("show", "Show preview"), ToolButton("hide", "Hide"), ToolButton("close", "Close")),
            listOf(ToolNumberInput("count", "Number of markers", count, 1, 64)))
    }
}

fun createPreviewTool(): ToolMod {
    val session = PreviewSession()
    return toolMod("preview-tool", "Preview tool") {
        service("preview.v1") { request -> session.event(previewPort(), request) }
        onTick { session.tick(previewPort()) }
        onStop { session.stop() }
    }
}

This demonstration tool distributes markers along the source track. It offers no build button. After Busy on read, clear, preview or panel, it keeps only presentation work and resumes on the next tick. Once positions are calculated, renewing them does not reread the network. A generation change discards the local plan and onStop releases state.

The graphical lease expires after two seconds without renewal. The SDK also retires presentations from an old world or stopped mod. Returning from showSignalPreview confirms publication, not visibility of every marker: framing, layers and editing of the source signal still apply.

Submit once, then track the ticket

ConstructionFollower.kt — state separate from the interface
package wiki.construction

import nimby.*

// Follows one operation; not a planner or an Apply button. Before confirmCreate,
// the caller must prepare, recapture and compare its explicitly approved plan.
interface ConstructionPort {
    val worldId: String
    val generation: Long
    fun create(ticket: Long, source: Long, positions: List<SignalPosition>): ConstructionResult
    fun undo(ticket: Long): ConstructionResult
    fun poll(ticket: Long): ConstructionResult
}

class ConstructionFollower {
    var result: ConstructionResult? = null; private set
    var pending = false; private set
    var panelOpen = true; private set
    private var world: Pair<String, Long>? = null
    private var undoIssued = false

    fun confirmCreate(port: ConstructionPort, prepared: ConstructionResult,
                      source: Long, approvedPositions: List<SignalPosition>) {
        require(!pending && prepared.state == ConstructionState.Ready && prepared.token != 0L)
        require(source != 0L && approvedPositions.size in 1..64)
        require(approvedPositions.all { it.fraction.isFinite() && it.fraction > 0 && it.fraction < 1 && (it.direction == 1 || it.direction == -1) })
        require(approvedPositions.map { it.trackId to it.fraction }.distinct().size == approvedPositions.size)
        check(result?.token != prepared.token) { "This ticket has already been submitted." }
        world = port.worldId to port.generation
        result = prepared
        undoIssued = false
        pending = true // BEFORE calling: exceptions can leave the outcome unknown.
        accept(port.create(prepared.token, source, approvedPositions))
    }

    fun confirmUndo(port: ConstructionPort) {
        check(world == (port.worldId to port.generation))
        val previous = requireNotNull(result)
        check(!pending && !undoIssued && previous.canUndo)
        undoIssued = true
        pending = true
        accept(port.undo(previous.token))
    }

    fun tick(port: ConstructionPort) {
        if (world != null && world != (port.worldId to port.generation)) { stop(); return }
        if (pending) accept(port.poll(requireNotNull(result).token))
    }

    private fun accept(next: ConstructionResult) {
        check(next.token == result?.token) { "A result from another ticket is not accepted." }
        result = next
        pending = next.state == ConstructionState.Pending
    }
    fun closePanel() { panelOpen = false } // Closing is not cancellation; keep polling.
    fun reopenPanel() { panelOpen = true }
    fun stop() { result = null; pending = false; world = null; undoIssued = false }
}

// Create for the current callback only; never retain this adapter/context.
fun ToolContext.constructionPort() = object : ConstructionPort {
    override val worldId get() = this@constructionPort.worldId
    override val generation get() = this@constructionPort.generation
    override fun create(ticket: Long, source: Long, positions: List<SignalPosition>) = createSignals(ticket, source, positions)
    override fun undo(ticket: Long) = undoConstruction(ticket)
    override fun poll(ticket: Long) = pollConstruction(ticket)
}

This helper deliberately leaves UI policy to the caller: your tool button must check the panel, preview, session and fresh plan comparison before confirmCreate. It marks pending before calling and keeps the ticket even if the call throws. The callback may display “checking outcome” and then call tick with the new context. Closing, reopening or recovering a panel never calls create or undo again.

ConstructionStateHandling
ReadyPreparation only. Another tool may take over an idle preparation after its deadline; prepare and validate when confirming.
PendingPoll the original ticket with pollConstruction. Do not submit another placement to obtain a response.
Applied / PartialInspect createdIds, reason and canUndo. Partial is not complete success. An explicit Undo click applies to that exact ticket.
Undone / RejectedDisplay the actual result and clear the pending state. Do not treat Rejected as successful placement.