NRF SDK 0.9

Créer un mod

Coopérer avec un autre mod

Reliez un bouton facultatif à un service et construisez un outil d’aperçu indépendant.

Déclarer le contrat entre les deux projets

Ce guide suppose un projet de signaux déjà fonctionnel. Ajoutez une action à son signalModel et créez un second projet Native pour le fournisseur. Le signal garde ses règles quand l’outil n’est pas installé ; whenMod n’ajoute pas une dépendance d’installation.

Fragment dans votre signalModel
action("preview", "Preview markers",
    whenMod = "preview-tool", service = "preview.v1")
ChampContrat
previewIdentifiant de cette action dans le modèle de signal.
preview-toolIdentifiant exact du mod fournisseur, déclaré dans son mod.json.
preview.v1Nom du service déclaré par le fournisseur avec service.

Le bouton devient disponible lorsque le fournisseur et son service sont présents avec une observation fraîche de la même partie. Un clic transmet une SignalActionRequest : source, action, service et portée de partie. Il ne donne à lui seul aucune autorisation de construire.

Choisir un service ou une fenêtre autonome

toolMod déclare un outil sans modèle de signal fictif. Déclarez au moins un service ou une fenêtre. Un service traite les actions de signaux ; une fenêtre permet aussi un outil sans signal sélectionné. Les callbacks utilisent le ToolContext reçu pendant l’appel.

DéclarationUsage
service(id) { request -> … }Recevoir un clic ou une édition valide d’un champ du panneau.
window(id, title, shortcut) { event -> … }Recevoir les événements d’une fenêtre autonome.
onTick { … }Avancer un travail borné ou renouveler un aperçu actif, sans attente.
onStop { … }Nettoyer l’état local lors d’un arrêt normal ; ne pas compter sur ce callback après un arrêt brutal.

Un mod accepte jusqu’à 32 services et 8 fenêtres, avec des identifiants distincts dans chaque groupe. Les handlers d’un même mod sont sérialisés : un handler qui attend empêche ses autres tâches d’avancer. Conservez des valeurs et un état de travail ; chaque callback reçoit un nouveau contexte utilisable.

Utiliser le contexte sans le conserver

Appel ou valeurRésultat utile
worldId / generationPortée des données : invalider calculs, source et état périmés lorsqu’elle change.
network()Nouvelle copie du réseau. Demander au début d’un calcul, pas à chaque renouvellement visuel.
showPanel(request, message, buttons, inputs)Panneau associé à l’action source : au plus 12 boutons et 4 champs entiers.
showSignalPreview(request, positions)Publication d’au plus 64 positions temporaires, sans poser de signal.
clearSignalPreview()Retirer l’aperçu de cet outil, sans toucher aux signaux construits.
log(message)Journaliser un événement ou un changement d’état utile.

Les copies Kotlin restent lisibles après le retour du callback ; le ToolContext ne reste pas utilisable. Une ancienne demande et ses positions doivent encore appartenir au worldId/generation courant avant une republication. Ne transformez pas un refus temporaire en résultat vide ou en réussite.

ToolOperationException.isBusy signale un refus temporaire. Pour un aperçu, conserver les positions et réessayer au callback suivant est possible ; aucune boucle d’attente n’est nécessaire. Pour une construction dont la réponse est incertaine, conservez le ticket et consultez son état au lieu de renvoyer la commande.

Exemple complet : un aperçu sans construction

Ce fichier place des repères à intervalles de fraction réguliers sur la voie du signal source. Il ne choisit aucune branche et ne calcule pas un espacement en mètres. La classe PreviewSession sépare la logique des appels SDK pour permettre des tests hors jeu.

src/main/kotlin/PreviewTool.kt
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() }
    }
}
Point d’entrée du projet outil : src/main/kotlin/Entry.kt
package nimby.mod

fun createMod() = wiki.preview.createPreviewTool()

Créez le projet avec le guide d’installation, puis fixez id à preview-tool dans mod.json pour correspondre à cet exemple. Il n’a pas besoin d’assets de signal. Installez ensemble le mod de signaux portant l’action et cet outil ; ouvrez l’action, choisissez un nombre puis Afficher l’aperçu. Vous devez voir plusieurs repères temporaires et aucun nouveau signal dans la partie.

Gérer édition, expiration et refus

  • Un champ vide ou invalide reste un brouillon. Le callback reçoit value seulement pour une valeur entière valide ; ne remplacez pas null par zéro.
  • Une nouvelle saisie masque l’ancien aperçu. Invalidez également votre ancien calcul avant toute opération susceptible d’échouer.
  • Publiez la liste complète des positions en un appel. L’aperçu expire après deux secondes sans renouvellement ; onTick peut renouveler les copies sans relire tout le réseau.
  • Un refus isBusy ne renouvelle pas l’aperçu. Désactivez toute confirmation de pose qui dépend de ce nouvel affichage jusqu’à une publication réussie.
  • Un seul aperçu est actif à la fois. Il reste lié à l’édition du signal source et disparaît à l’arrêt du mod ou de la partie.

Fermer l’interface sans perdre le suivi

Conservez séparément l’état ouvert/fermé, les valeurs du formulaire, l’aperçu et le ticket éventuel. Pour replier le panneau, retirez l’aperçu, republiez seulement le bouton d’origine et cessez de renouveler les anciens contrôles. PreviewSession réalise cette séparation.

Fragment dans un callback de fermeture
clearSignalPreview()
showPanel(request, "", listOf(
    ToolButton(request.originAction, "Open tool")
))

Le prochain clic utilise originAction et peut rouvrir le formulaire. Une erreur de retrait doit laisser la confirmation locale désactivée, avec nettoyage à reprendre. Fermer le panneau n’annule pas une construction déjà envoyée : continuez de consulter son ticket jusqu’à un état terminal.