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 state | Allowed action |
|---|---|
| Calculation requested | Read the network once, compute a bounded plan and retain copied values. A ticket never obliges the tool to build. |
| Presentation pending | Retry the read or presentation on a later onTick. Keep Apply disabled and never submit a command in place of a click. |
| Preview published | Renew the same positions without capturing the whole network each tick. Before a build command, prepare, read again and compare. |
| Command submitted | Retain the ticket and prevent a second submission. While the outcome is uncertain, repeat only status polling. |
| Panel closed | Revoke 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
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
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.
| ConstructionState | Handling |
|---|---|
| Ready | Preparation only. Another tool may take over an idle preparation after its deadline; prepare and validate when confirming. |
| Pending | Poll the original ticket with pollConstruction. Do not submit another placement to obtain a response. |
| Applied / Partial | Inspect createdIds, reason and canUndo. Partial is not complete success. An explicit Undo click applies to that exact ticket. |
| Undone / Rejected | Display the actual result and clear the pending state. Do not treat Rejected as successful placement. |