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’outil | Action 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 attente | Ré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ée | Conserver 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
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
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.
| ConstructionState | Traitement |
|---|---|
| Ready | Préparation seulement. Un autre outil peut reprendre une préparation inactive à son échéance ; préparez et validez au moment de confirmer. |
| Pending | Interroger le ticket original avec pollConstruction. Ne pas soumettre une nouvelle pose pour obtenir une réponse. |
| Applied / Partial | Examiner createdIds, reason et canUndo. Partial n’est pas un succès complet. Un clic Annuler explicite porte sur ce ticket exact. |
| Undone / Rejected | Afficher le résultat réel et lever l’attente. Ne pas transformer Rejected en confirmation de pose. |