NRF SDK 0.9

Créer un mod

Aperçu, confirmation et suivi d’une opération

Construire une interface réactive qui attend sans perdre le plan et ne rejoue jamais une commande incertaine.

Séparer le calcul, l’affichage et la commande

Prérequis : un service qui reçoit une SignalActionRequest et un calcul de positions borné. Ce guide construit un cycle dans lequel le joueur voit un plan, le confirme, puis reçoit le résultat réel de la commande. Vérifiez la source, worldId, generation et l’état local du panneau. Une saisie, une source ou une session différente invalide immédiatement la confirmation précédente.

Une requête déjà reçue ne doit pas appliquer un nouveau plan sans confirmation. Les copies de réseau, positions et tickets peuvent rester dans votre état local ; ToolContext et les objets qui le capturent ne doivent pas survivre au callback. Utilisez le nouveau contexte du prochain événement ou onTick pour poursuivre.

État de l’outilAction autorisée
Calcul demandéLire une fois le réseau, construire un plan borné, garder les valeurs copiées. Aucun ticket n’est une obligation de construire.
Présentation en attenteRéessayer la lecture ou la présentation au prochain onTick. Garder Appliquer désactivé et ne pas lancer une commande à la place du clic.
Aperçu publiéRenouveler les mêmes positions sans capturer tout le réseau à chaque tick. Avant le clic de pose, préparer puis relire et comparer.
Commande envoyéeConserver le ticket et interdire toute seconde soumission. Tant que le résultat est incertain, seuls les appels de suivi sont renouvelés.
Panneau ferméRévoquer l’aperçu et les clics locaux ; continuer à suivre une commande déjà envoyée. Fermer n’annule pas une pose.

Traiter une indisponibilité temporaire

ToolOperationException.isBusy indique que l’opération ne peut pas être servie maintenant. Ce n’est pas un défaut permanent du modèle. Une lecture, showSignalPreview, clearSignalPreview ou showPanel peut être retentée lors d’un prochain callback, au plus une tentative par tick. Conservez un drapeau de travail restant et rendez la main ; n’ajoutez ni boucle d’attente, ni temporisation bloquante, ni file de tentatives illimitée.

Un refus de publication ne renouvelle pas l’ancien aperçu. Un refus de clear ne confirme pas son retrait : révoquez d’abord l’autorisation locale de poser et gardez le nettoyage à faire. Un refus de panneau n’autorise pas un clic resté dans l’ancien panneau. Les autres erreurs demandent un diagnostic et l’abandon du plan périmé ; ne classez pas toutes les exceptions comme Busy. Même log peut échouer temporairement : un diagnostic ne doit pas modifier l’état de votre opération ni provoquer une nouvelle boucle de diagnostics.

Un exemple sans construction

PreviewTool.kt — aperçu et reprise de présentation
package wiki.preview

import nimby.*

// Marqueurs graphiques seulement : ni planificateur de route, ni commande de pose.
fun previewPositions(network: ToolNetwork, sourceId: Long, count: Int): List<SignalPosition> {
    require(count in 1..64)
    val source = requireNotNull(network.topology().signal(sourceId)) { "Source ou voie indisponible." }
    return (1..count).map { source.placementAt(source.track, it.toDouble() / (count + 1)) }
}

// Interface de test autour des appels publics ; recréée à chaque callback, jamais conservée.
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 = "Choisissez le nombre de marqueurs."
    private var idleMessage = message

    fun event(port: PreviewPort, next: SignalActionRequest) {
        if (next.worldId != port.worldId || next.generation != port.generation) return
        request = next
        positions = null // Révoquer localement AVANT les appels qui peuvent être refusés.
        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 "Aperçu masqué."
        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) // Renouveler les positions copiées, sans nouvelle capture.
                setMessage("Affichage de ${it.size} marqueurs temporaires.")
            }
        } catch (error: ToolOperationException) {
            if (error.isBusy) setMessage("Service occupé ; affichage de l’aperçu en attente.")
            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 = "Aperçu indisponible ; demandez un nouvel aperçu."
        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, "Ouvrir l’outil d’aperçu")))
        else showPanel(request, message,
            listOf(ToolButton("show", "Afficher l’aperçu"), ToolButton("hide", "Masquer"), ToolButton("close", "Fermer")),
            listOf(ToolNumberInput("count", "Nombre de marqueurs", count, 1, 64)))
    }
}

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

Cet outil de démonstration répartit des marqueurs sur la voie source. Il n’offre aucun bouton de pose. Après Busy sur lecture, effacement, aperçu ou panneau, il garde seulement le travail de présentation et reprend au tick suivant. Une fois les positions calculées, leur renouvellement ne relit pas le réseau. Le changement de génération détruit le plan local et onStop libère l’état.

Le bail graphique expire après deux secondes sans renouvellement. Le SDK retire aussi les présentations de l’ancien monde ou d’un mod arrêté. Le retour de showSignalPreview confirme une publication, pas la visibilité de chaque marqueur : cadrage, couches et édition du signal source continuent de s’appliquer.

Soumettre une fois, puis suivre le ticket

ConstructionFollower.kt — état séparé de l’interface
package wiki.construction

import nimby.*

// Suit une opération ; ce n’est ni un planificateur ni un bouton Appliquer. Avant confirmCreate,
// l’appelant prépare, relit et compare le plan explicitement approuvé.
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) { "Ce ticket a déjà été envoyé." }
        world = port.worldId to port.generation
        result = prepared
        undoIssued = false
        pending = true // AVANT l’appel : une exception peut laisser le résultat inconnu.
        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) { "Un résultat d’un autre ticket est refusé." }
        result = next
        pending = next.state == ConstructionState.Pending
    }
    fun closePanel() { panelOpen = false } // Fermer n’est pas annuler ; continuer le suivi.
    fun reopenPanel() { panelOpen = true }
    fun stop() { result = null; pending = false; world = null; undoIssued = false }
}

// Créer pour le callback courant seulement ; ne jamais conserver cet adaptateur/contexte.
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)
}

Le helper est volontairement incomplet du côté interface : le bouton de votre outil doit vérifier le panneau, l’aperçu, la session et la comparaison fraîche du plan avant confirmCreate. Il marque pending avant l’appel et conserve le ticket même si cet appel lève une exception. Le callback peut afficher « résultat en cours de vérification » puis utiliser tick avec le nouveau contexte. Aucune fermeture, réouverture ou récupération de panneau ne rappelle create ou undo.

ConstructionStateTraitement
ReadyPréparation seulement. Un autre outil peut reprendre une préparation inactive à son échéance ; préparez et validez au moment de confirmer.
PendingInterroger le ticket original avec pollConstruction. Ne pas soumettre une nouvelle pose pour obtenir une réponse.
Applied / PartialExaminer createdIds, reason et canUndo. Partial n’est pas un succès complet. Un clic Annuler explicite porte sur ce ticket exact.
Undone / RejectedAfficher le résultat réel et lever l’attente. Ne pas transformer Rejected en confirmation de pose.