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.
action("preview", "Preview markers",
whenMod = "preview-tool", service = "preview.v1")| Champ | Contrat |
|---|---|
| preview | Identifiant de cette action dans le modèle de signal. |
| preview-tool | Identifiant exact du mod fournisseur, déclaré dans son mod.json. |
| preview.v1 | Nom 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éclaration | Usage |
|---|---|
| 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 valeur | Résultat utile |
|---|---|
| worldId / generation | Porté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.
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() }
}
}
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.