From 5857a005fb2faf424a798f8998004b90d0bdad1b Mon Sep 17 00:00:00 2001 From: chikage Date: Fri, 26 Jun 2026 02:03:03 +0200 Subject: [PATCH 01/12] fix: stop player fact loss when switching servers Two defects in the per-player fact sync could delete facts from the shared MongoDB on a server switch: 1. reconcileGroup() keyed its delete-missing on the *persistable* subset of the cache. Any fact whose entry isn't defined/loaded on the current server failed isPersistable() and was wiped from the shared store (symptom: "Loaded 10 -> Synced 2 -> lost 8"). Deletion is now keyed on cache *presence*: reconcileGroup(groupId, upserts, present) only removes facts the player genuinely no longer has, never ones this server merely can't persist. 2. The periodic async flush could run after a player was evicted on quit, observing an empty cache and issuing deleteMany() that wiped every fact. FactSessionSync now gates flushes on per-player ownership and serialises load/flush/evict with a striped lock, so an evicted or never-loaded player is never reconciled. Adds an injectable cache provider to FactSessionSync for testing, a MockK unit test for the eviction race, and Testcontainers tests covering presence-based reconciliation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../inkwell/storage/FactSessionSync.kt | 106 ++++++++++++------ .../inkwell/storage/MongoFactStorage.kt | 25 ++++- .../inkwell/storage/DbFactStorageTest.kt | 50 ++++++++- .../inkwell/storage/FactSessionSyncTest.kt | 70 ++++++++++++ 4 files changed, 210 insertions(+), 41 deletions(-) create mode 100644 src/test/kotlin/fr/perrier/inkwell/storage/FactSessionSyncTest.kt diff --git a/src/main/kotlin/fr/perrier/inkwell/storage/FactSessionSync.kt b/src/main/kotlin/fr/perrier/inkwell/storage/FactSessionSync.kt index 795c9e1..20a4400 100644 --- a/src/main/kotlin/fr/perrier/inkwell/storage/FactSessionSync.kt +++ b/src/main/kotlin/fr/perrier/inkwell/storage/FactSessionSync.kt @@ -19,47 +19,84 @@ import java.util.logging.Logger * [FactDatabase] keeps all facts in a private `final` cache with no per-player hook, so we reach it by * reflection. Facts are scoped by [FactId.groupId] — `== player UUID` for the default per-player case. * Named-group facts aren't handled here; they ride the periodic upsert. + * + * Two invariants keep a sync from ever destroying data: + * - **Ownership** ([owned]): a group is only reconciled while this server has it loaded. A flush for a + * player who was evicted (server switch / quit) or never loaded would see an empty cache and wipe + * every one of their facts. Such flushes are skipped. + * - **Presence, not persistability**: deletion is keyed on what's still in the cache, never on the + * subset this server can persist — so a fact whose entry isn't defined here is kept, not deleted. */ class FactSessionSync( private val storage: MongoFactStorage, private val logger: Logger, + cacheProvider: () -> MutableMap? = { reflectFactCache(logger) }, ) { - @Suppress("UNCHECKED_CAST") - private val cache: MutableMap? by lazy { - runCatching { - val factDatabase = GlobalContext.get().get() - val field = FactDatabase::class.java.getDeclaredField("cache").apply { isAccessible = true } - field.get(factDatabase) as MutableMap - }.onFailure { - logger.severe("Could not access FactDatabase cache via reflection — cross-server facts disabled: ${it.message}") - }.getOrNull() - } + private val cache: MutableMap? by lazy(cacheProvider) // Last raw per-group cache state we observed, to skip periodic writes when nothing changed. private val lastRaw = ConcurrentHashMap>() + // Groups this server instance currently has loaded. Only these may be reconciled (see class doc). + private val owned = ConcurrentHashMap.newKeySet() + + // Per-group lock (striped) so a periodic flush can't observe a half-evicted cache mid-quit, and so + // load/flush/evict for one player never interleave. Fixed-size: no per-player allocation or leak. + private val locks = Array(16) { Any() } + private fun lockFor(gid: String): Any = locks[(gid.hashCode() and 0x7fffffff) % locks.size] + /** Load a player's persisted facts from Mongo into Typewriter's cache (call on pre-login). */ fun loadInto(uuid: UUID) { val cache = cache ?: return - runCatching { - val gid = uuid.toString() - val facts = runBlocking { storage.loadFactsForGroup(gid) } - // The DB is authoritative for this player: drop any stale local entries, then apply. - cache.keys.removeAll { it.groupId.id == gid } - cache.putAll(facts) - lastRaw[gid] = facts - if (facts.isNotEmpty()) logger.info("Loaded ${facts.size} fact(s) for $uuid from MongoDB") - }.onFailure { logger.warning("Failed to load facts for $uuid: ${it.message}") } + val gid = uuid.toString() + synchronized(lockFor(gid)) { + runCatching { + val facts = runBlocking { storage.loadFactsForGroup(gid) } + // The DB is authoritative for this player: drop any stale local entries, then apply. + cache.keys.removeAll { it.groupId.id == gid } + cache.putAll(facts) + lastRaw[gid] = facts + owned.add(gid) // we now own this player's facts on this server + if (facts.isNotEmpty()) logger.info("Loaded ${facts.size} fact(s) for $uuid from MongoDB") + }.onFailure { logger.warning("Failed to load facts for $uuid: ${it.message}") } + } } /** * Reconcile a player's facts to Mongo *if they changed* since we last looked (no eviction). * Called periodically so command-based changes (/tw facts set|add|reset), which don't fire the * write-through trigger, still reach the DB. A cheap in-memory diff skips unchanged players. + * + * No-ops for players this server doesn't own: the periodic task captures the online list a tick + * before running, so a player can quit (and be evicted) in between — reconciling them then would + * delete their facts from an empty cache. */ fun flush(uuid: UUID) { val cache = cache ?: return val gid = uuid.toString() + if (gid !in owned) return // fast path: not ours — never reconcile + synchronized(lockFor(gid)) { + if (gid !in owned) return // re-check under lock (a concurrent quit may have evicted) + flushLocked(gid, uuid, cache) + } + } + + /** Flush a player's facts then evict them from the cache (call on quit / shutdown). */ + fun flushAndEvict(uuid: UUID) { + val cache = cache ?: return + val gid = uuid.toString() + synchronized(lockFor(gid)) { + if (gid in owned) flushLocked(gid, uuid, cache) // final reconcile while still loaded + owned.remove(gid) // stop owning: any later/concurrent flush now no-ops + runCatching { + cache.keys.removeAll { it.groupId.id == gid } + lastRaw.remove(gid) + }.onFailure { logger.warning("Failed to evict facts for $uuid: ${it.message}") } + } + } + + /** Core reconcile. Caller must hold [lockFor] and have verified ownership. */ + private fun flushLocked(gid: String, uuid: UUID, cache: MutableMap) { runCatching { val rawCurrent = cache.entries .filter { it.key.groupId.id == gid } @@ -67,24 +104,15 @@ class FactSessionSync( if (rawCurrent == lastRaw[gid]) return@runCatching // nothing changed for this player lastRaw[gid] = rawCurrent - val persistable = rawCurrent.filter { (id, data) -> isPersistable(id, data) } + // Upsert only what this server can persist, but key deletion on everything still present — + // so facts whose entry isn't defined here are kept, not wiped (see MongoFactStorage docs). + val upserts = rawCurrent.filter { (id, data) -> isPersistable(id, data) } .map { it.key to it.value } - runBlocking { storage.reconcileGroup(gid, persistable) } - logger.info("Synced ${persistable.size} fact(s) for $uuid to MongoDB") + runBlocking { storage.reconcileGroup(gid, upserts, rawCurrent.keys) } + logger.info("Synced ${upserts.size} fact(s) for $uuid to MongoDB") }.onFailure { logger.warning("Failed to sync facts for $uuid: ${it.message}") } } - /** Flush a player's facts then evict them from the cache (call on quit / shutdown). */ - fun flushAndEvict(uuid: UUID) { - val cache = cache ?: return - flush(uuid) - runCatching { - val gid = uuid.toString() - cache.keys.removeAll { it.groupId.id == gid } - lastRaw.remove(gid) - }.onFailure { logger.warning("Failed to evict facts for $uuid: ${it.message}") } - } - /** Mirrors Typewriter's own persistence filter: only persistable, non-expired facts are stored. */ private fun isPersistable(id: FactId, data: FactData): Boolean { val entry = Query.findById(id.entryId) ?: return false @@ -92,4 +120,16 @@ class FactSessionSync( if (entry is ExpirableFactEntry && entry.hasExpired(id, data)) return false return true } + + companion object { + @Suppress("UNCHECKED_CAST") + private fun reflectFactCache(logger: Logger): MutableMap? = + runCatching { + val factDatabase = GlobalContext.get().get() + val field = FactDatabase::class.java.getDeclaredField("cache").apply { isAccessible = true } + field.get(factDatabase) as MutableMap + }.onFailure { + logger.severe("Could not access FactDatabase cache via reflection — cross-server facts disabled: ${it.message}") + }.getOrNull() + } } diff --git a/src/main/kotlin/fr/perrier/inkwell/storage/MongoFactStorage.kt b/src/main/kotlin/fr/perrier/inkwell/storage/MongoFactStorage.kt index 365dbb8..afba56c 100644 --- a/src/main/kotlin/fr/perrier/inkwell/storage/MongoFactStorage.kt +++ b/src/main/kotlin/fr/perrier/inkwell/storage/MongoFactStorage.kt @@ -91,17 +91,34 @@ class MongoFactStorage( } } - /** Makes the DB match [facts] for one group only (upsert + delete-missing); other groups untouched. */ - suspend fun reconcileGroup(groupId: String, facts: Collection>) { + /** + * Reconciles one group's facts in the DB without touching other groups. + * + * [upserts] are the facts to write (the changed/persistable ones). [present] is the FULL set of + * facts this server currently holds in cache for the group — **including** ones it cannot persist + * right now (e.g. their entry isn't defined or isn't loaded on this server). Deletion is keyed on + * [present]: only facts ABSENT from it are removed. That means a fact the player genuinely no longer + * has (e.g. `/tw facts reset`), never a fact that merely failed this server's persistability check. + * + * Keying deletion on the *persistable* subset instead would wipe another server's facts from the + * shared store on every sync (load 10 → "synced 2" → 8 silently deleted) — the bug this guards. + */ + suspend fun reconcileGroup( + groupId: String, + upserts: Collection>, + present: Collection, + ) { withContext(IO) { runCatching { - val keepKeys = facts.map { (id, _) -> compositeKey(id) } + val keepKeys = present.map { compositeKey(it) } val groupFilter = Filters.eq("groupId", groupId) if (keepKeys.isEmpty()) { collection.deleteMany(groupFilter) } else { collection.deleteMany(Filters.and(groupFilter, Filters.nin("_id", keepKeys))) - collection.bulkWrite(facts.map { (id, data) -> upsertOf(id, data) }) + } + if (upserts.isNotEmpty()) { + collection.bulkWrite(upserts.map { (id, data) -> upsertOf(id, data) }) } }.onFailure { logger.warning("Mongo reconcileGroup($groupId) failed: ${it.message}") } } diff --git a/src/test/kotlin/fr/perrier/inkwell/storage/DbFactStorageTest.kt b/src/test/kotlin/fr/perrier/inkwell/storage/DbFactStorageTest.kt index 4256bde..6362a67 100644 --- a/src/test/kotlin/fr/perrier/inkwell/storage/DbFactStorageTest.kt +++ b/src/test/kotlin/fr/perrier/inkwell/storage/DbFactStorageTest.kt @@ -5,6 +5,7 @@ import com.typewritermc.engine.paper.entry.entries.GroupId import com.typewritermc.engine.paper.facts.FactData import com.typewritermc.engine.paper.facts.FactId import fr.perrier.inkwell.config.DatabaseConfig +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.Assertions.assertEquals @@ -16,6 +17,7 @@ import org.testcontainers.containers.MongoDBContainer import org.testcontainers.utility.DockerImageName import java.time.LocalDateTime import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import java.util.logging.Logger @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -99,8 +101,8 @@ class DbFactStorageTest { storage.storeFacts(listOf(a to FactData(1, now), b to FactData(2, now), c to FactData(3, now))) - // grp1 now only has 'a' (updated); 'b' must be removed. grp2 must be untouched. - storage.reconcileGroup(grp1.id, listOf(a to FactData(10, now))) + // grp1 now only has 'a' (updated); 'b' is gone from the cache so it must be removed. grp2 untouched. + storage.reconcileGroup(grp1.id, upserts = listOf(a to FactData(10, now)), present = listOf(a)) val loaded1 = storage.loadFactsForGroup(grp1.id) assertEquals(1, loaded1.size) @@ -110,18 +112,58 @@ class DbFactStorageTest { } @Test - fun `reconcileGroup with empty set clears the group`() = runTest { + fun `reconcileGroup with empty present set clears the group`() = runTest { val now = LocalDateTime.parse("2026-05-23T10:30:00") val grp = GroupId(UUID.randomUUID()) storage.storeFacts(listOf(FactId("x", grp) to FactData(1, now))) - storage.reconcileGroup(grp.id, emptyList()) + storage.reconcileGroup(grp.id, upserts = emptyList(), present = emptyList()) assertTrue(storage.loadFactsForGroup(grp.id).isEmpty()) } + @Test + fun `reconcileGroup keeps present facts it did not upsert (cross-server entries)`() = runTest { + val now = LocalDateTime.parse("2026-05-23T10:30:00") + val grp = GroupId(UUID.randomUUID()) + val ids = (1..10).map { FactId("fact_$it", grp) } + storage.storeFacts(ids.mapIndexed { i, id -> id to FactData(i + 1, now) }) + + // This server can only persist 2 of the 10 (the other 8 entries aren't defined here), but all + // 10 are still present in its cache. The 8 it can't persist must NOT be deleted. + val upserts = listOf(ids[0] to FactData(100, now), ids[1] to FactData(200, now)) + storage.reconcileGroup(grp.id, upserts = upserts, present = ids) + + val loaded = storage.loadFactsForGroup(grp.id) + assertEquals(10, loaded.size) // nothing lost + assertEquals(100, loaded[ids[0]]?.value) // the 2 it owned were updated + assertEquals(200, loaded[ids[1]]?.value) + assertEquals(3, loaded[ids[2]]?.value) // an untouched one keeps its original value + } + @Test fun `loadFacts returns empty in the per-player model`() = runTest { assertTrue(storage.loadFacts().isEmpty()) } + + @Test + fun `a late flush after eviction never wipes a player's facts (server-switch race)`() { + val uuid = UUID.randomUUID() + val grp = GroupId(uuid) + val now = LocalDateTime.parse("2026-05-23T10:30:00") + val kills = FactId("kills", grp) + runBlocking { storage.storeFacts(listOf(kills to FactData(7, now))) } + + // FactSessionSync over an in-memory stand-in for Typewriter's FactDatabase cache. + val cache = ConcurrentHashMap() + val sync = FactSessionSync(storage, Logger.getLogger("FactSessionSyncTest")) { cache } + + sync.loadInto(uuid) // server loads the player on connect + sync.flushAndEvict(uuid) // player switches away -> facts flushed and evicted + sync.flush(uuid) // a periodic flush captured just before the quit fires late + + // The player is no longer owned by this server, so the late flush must be a no-op. + val loaded = runBlocking { storage.loadFactsForGroup(uuid.toString()) } + assertEquals(7, loaded[kills]?.value) + } } diff --git a/src/test/kotlin/fr/perrier/inkwell/storage/FactSessionSyncTest.kt b/src/test/kotlin/fr/perrier/inkwell/storage/FactSessionSyncTest.kt new file mode 100644 index 0000000..59d9a56 --- /dev/null +++ b/src/test/kotlin/fr/perrier/inkwell/storage/FactSessionSyncTest.kt @@ -0,0 +1,70 @@ +package fr.perrier.inkwell.storage + +import com.typewritermc.engine.paper.entry.entries.GroupId +import com.typewritermc.engine.paper.facts.FactData +import com.typewritermc.engine.paper.facts.FactId +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Logger + +/** + * Unit tests for [FactSessionSync] over an in-memory stand-in for Typewriter's FactDatabase cache. + * No MongoDB needed — the storage layer is mocked, so these run without Docker. + */ +class FactSessionSyncTest { + + private val logger = Logger.getLogger("FactSessionSyncTest") + private val now: LocalDateTime = LocalDateTime.parse("2026-05-23T10:30:00") + + @Test + fun `a late flush after eviction never reconciles an evicted player (server-switch race)`() { + val uuid = UUID.randomUUID() + val kills = FactId("kills", GroupId(uuid)) + + val storage = mockk() + coEvery { storage.loadFactsForGroup(uuid.toString()) } returns mapOf(kills to FactData(7, now)) + coEvery { storage.reconcileGroup(any(), any(), any()) } returns Unit + + val cache = ConcurrentHashMap() + val sync = FactSessionSync(storage, logger) { cache } + + sync.loadInto(uuid) // server loads the player on connect + sync.flushAndEvict(uuid) // player switches away -> facts flushed and evicted + sync.flush(uuid) // a periodic flush captured just before the quit fires late + + // The player is no longer owned by this server: reconciling an empty cache would wipe the DB. + coVerify(exactly = 0) { storage.reconcileGroup(uuid.toString(), any(), any()) } + } + + @Test + fun `flush keeps every cached fact present even when none are persistable here`() { + val uuid = UUID.randomUUID() + val grp = GroupId(uuid) + + val storage = mockk() + coEvery { storage.loadFactsForGroup(uuid.toString()) } returns emptyMap() + val present = slot>() + coEvery { storage.reconcileGroup(uuid.toString(), any(), capture(present)) } returns Unit + + val cache = ConcurrentHashMap() + val sync = FactSessionSync(storage, logger) { cache } + sync.loadInto(uuid) // empty load -> player is owned, lastRaw = {} + + // Facts now appear in Typewriter's cache — e.g. loaded for entries this server doesn't define, + // so isPersistable() rejects them all. They must still be reported as "present" (kept), not deleted. + val ids = (1..10).map { FactId("fact_$it", grp) } + ids.forEachIndexed { i, id -> cache[id] = FactData(i + 1, now) } + + sync.flush(uuid) + + coVerify(exactly = 1) { storage.reconcileGroup(uuid.toString(), any(), any()) } + assertEquals(10, present.captured.size) // deletion keyed on presence, not persistability + } +} From 0a608af870bc0a2f15fbd834cbecbfd93399dee0 Mon Sep 17 00:00:00 2001 From: chikage Date: Fri, 26 Jun 2026 02:03:15 +0200 Subject: [PATCH 02/12] feat: support separate MongoDB credentials and apply connection timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DatabaseConfig only read `uri` and `name`, so the `username`, `password` and `auth_source` config keys were silently ignored — a secured MongoDB rejected every operation with "Command find requires authentication" (error 13). The Mongo client is now built from MongoClientSettings: - optional username/password/auth_source are applied as a MongoCredential (auth_source defaults to the database name), so the password no longer has to be URL-encoded into the URI; an explicit credential overrides one embedded in the URI; - `timeout_ms` (previously parsed but unused) is applied as the server selection timeout. The default config.yml documents the new auth keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fr/perrier/inkwell/InkwellModule.kt | 28 ++++++++++++++++++- .../perrier/inkwell/config/DatabaseConfig.kt | 9 ++++++ src/main/resources/config.yml | 6 ++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/fr/perrier/inkwell/InkwellModule.kt b/src/main/kotlin/fr/perrier/inkwell/InkwellModule.kt index e78440c..b382336 100644 --- a/src/main/kotlin/fr/perrier/inkwell/InkwellModule.kt +++ b/src/main/kotlin/fr/perrier/inkwell/InkwellModule.kt @@ -3,6 +3,9 @@ package fr.perrier.inkwell import fr.perrier.inkwell.config.DatabaseConfig import fr.perrier.inkwell.storage.FactWriteThroughHandler import fr.perrier.inkwell.storage.MongoFactStorage +import com.mongodb.ConnectionString +import com.mongodb.MongoClientSettings +import com.mongodb.MongoCredential import com.mongodb.kotlin.client.coroutine.MongoClient import com.mongodb.kotlin.client.coroutine.MongoDatabase import com.typewritermc.engine.paper.facts.FactStorage @@ -10,12 +13,13 @@ import com.typewritermc.engine.paper.interaction.TriggerHandler import org.koin.core.module.Module import org.koin.dsl.bind import org.koin.dsl.module +import java.util.concurrent.TimeUnit import java.util.logging.Logger fun inkwellModule(databaseConfig: DatabaseConfig, logger: Logger): Module = module { single { databaseConfig } single { logger } - single { MongoClient.create(get().uri) } + single { buildMongoClient(get()) } single { get().getDatabase(get().databaseName) } // Only override Typewriter's FactStorage when fact persistence is enabled; otherwise Typewriter @@ -27,3 +31,25 @@ fun inkwellModule(databaseConfig: DatabaseConfig, logger: Logger): Module = modu single { FactWriteThroughHandler(get() as MongoFactStorage, get()) } bind TriggerHandler::class } } + +/** + * Builds the Mongo client from the connection string, layering on the optional separate credentials + * ([DatabaseConfig.username]/[password]/[authSource]) and the configured server-selection timeout. + * Keeping credentials out of the URI means the password needs no URL-encoding, and an explicit + * credential here overrides any embedded in the URI. + */ +private fun buildMongoClient(config: DatabaseConfig): MongoClient { + val settings = MongoClientSettings.builder() + .applyConnectionString(ConnectionString(config.uri)) + .applyToClusterSettings { it.serverSelectionTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) } + .apply { + val user = config.username + val pass = config.password + if (!user.isNullOrBlank() && pass != null) { + val source = config.authSource?.takeIf { it.isNotBlank() } ?: config.databaseName + credential(MongoCredential.createCredential(user, source, pass.toCharArray())) + } + } + .build() + return MongoClient.create(settings) +} diff --git a/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt b/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt index 151c298..d3499e4 100644 --- a/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt +++ b/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt @@ -14,6 +14,12 @@ data class DatabaseConfig( val persistPages: Boolean, val persistSnippets: Boolean, val factSyncIntervalSeconds: Long, + // Optional credentials. When [username] is set they are applied as a separate MongoCredential, + // so the password never has to be URL-encoded into [uri]. Leave blank to authenticate purely + // through the connection string (or not at all). [authSource] defaults to [databaseName]. + val username: String? = null, + val password: String? = null, + val authSource: String? = null, ) { companion object { fun from(config: FileConfiguration): DatabaseConfig { @@ -25,6 +31,9 @@ data class DatabaseConfig( ?: error("database.uri missing in config.yml"), databaseName = section.getString("name") ?: error("database.name missing in config.yml"), + username = section.getString("username")?.takeIf { it.isNotBlank() }, + password = section.getString("password")?.takeIf { it.isNotBlank() }, + authSource = section.getString("auth_source")?.takeIf { it.isNotBlank() }, factsCollection = section.getString("facts_collection") ?: "facts", pagesCollection = section.getString("pages_collection") ?: "pages", filesCollection = section.getString("files_collection") ?: "files", diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 6202531..8a1295f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,6 +1,12 @@ database: uri: "mongodb://localhost:27017" name: "inkwell" + # Optional authentication. If your MongoDB requires a login, set these instead of embedding the + # credentials in the URI (no URL-encoding needed). Leave blank to skip auth. auth_source is the + # database the user was created in (often "admin", or the same as 'name'); blank defaults to 'name'. + username: "" + password: "" + auth_source: "" # Collection holding player facts (FactStorage override). facts_collection: "facts" # Collection holding published Typewriter pages (one document per page). From 08dadadc4548cd86fd373cdea0a66abf83a7a96f Mon Sep 17 00:00:00 2001 From: chikage Date: Fri, 26 Jun 2026 02:03:24 +0200 Subject: [PATCH 03/12] fix: keep the Mongo connection open across runtime reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typewriter resolves Inkwell's FactStorage once via `by lazy` at its own startup and caches it (FactDatabase.storage). Closing the MongoClient on a runtime disable (PlugMan / /reload) therefore left that cached binding pointing at a dead connection, so Typewriter's periodic storeFacts failed with "state should be: open". onDisable no longer closes the client or unloads the Koin module on a reload — it still flushes online players' facts first, so nothing is lost. Because the binding is cached, a full server restart is needed to re-point Typewriter's hook; the README's reload note documents this. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 9 +++++++++ src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt | 10 +++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ab7da7b..fdb8aa9 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,15 @@ Stack: Kotlin 2.3.20, JDK 21, Gradle 9.5, MongoDB Kotlin Coroutine driver 5.2. warning rather than crashing. - **No retry** on a MongoDB outage: errors are logged and the server keeps running, but data isn't persisted while the database is unreachable. +- **Hot reload (PlugMan / `/reload`)**: works and won't lose facts — `onDisable` flushes every online + player before the plugin unloads. Inkwell deliberately does **not** close the MongoDB connection on a + runtime disable: Typewriter resolves Inkwell's `FactStorage` once via `by lazy` at *its own* startup + and caches it (`FactDatabase.storage`), so closing the client would leave that cached binding pointing + at a dead connection (`Mongo storeFacts failed: state should be: open`). Leaving it open keeps + Typewriter writing. **Caveat:** because that binding is cached, reloading Inkwell **alone** does not + re-point Typewriter at freshly-loaded code/config, and each reload leaves the previous load's + connection around until the JVM stops. For a clean state — and to pick up new code/config in the + Typewriter hook — **restart the server** (or reload Typewriter alongside Inkwell). ## License diff --git a/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt b/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt index 5dde352..8da3b98 100644 --- a/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt +++ b/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt @@ -96,15 +96,19 @@ class InkwellPlugin : JavaPlugin() { override fun onDisable() { // Reconcile still-online players so deletions (e.g. /tw facts reset) reach the DB even when - // their quit doesn't fire before disable — Typewriter's shutdown flush is upsert-only. + // their quit doesn't fire before disable — Typewriter's shutdown flush is upsert-only. This + // runs on both a server stop and a runtime reload (PlugMan / /reload), so facts are never lost. factSync?.let { sync -> server.onlinePlayers.forEach { sync.flushAndEvict(it.uniqueId) } } backupAll() - // Do NOT close the MongoClient or unload the module: we disable before Typewriter, whose - // FactDatabase.shutdown() still calls our FactStorage afterwards. The JVM reclaims it on stop. + // Do NOT close the MongoClient or unload the Koin module here — on a *reload* as well as a stop. + // Typewriter resolves our FactStorage once via `by lazy` and caches it (FactDatabase.storage), + // and on shutdown it disables AFTER us and still calls that FactStorage. Closing the client on a + // runtime reload would leave Typewriter's cached binding pointing at a dead connection + // ("state should be: open"). Leaving it open keeps Typewriter working; the JVM reclaims it on stop. logger.info("Inkwell disabled") } From c2428dae0517dc45956a0f121ebed2edc3b4dd36 Mon Sep 17 00:00:00 2001 From: chikage Date: Sat, 27 Jun 2026 23:04:27 +0200 Subject: [PATCH 04/12] docs: design for cross-server fact placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specs the fact_catalog (name↔id, upsert-only) + PlaceholderAPI %inkwell_fact_% reading the value from Typewriter's already-loaded fact cache, so facts display on servers without the defining page — no page merging. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DSbnaoxxpzqfrNexWsaVj5 --- ...27-cross-server-fact-placeholder-design.md | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-27-cross-server-fact-placeholder-design.md diff --git a/docs/superpowers/specs/2026-06-27-cross-server-fact-placeholder-design.md b/docs/superpowers/specs/2026-06-27-cross-server-fact-placeholder-design.md new file mode 100644 index 0000000..fa48f17 --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-cross-server-fact-placeholder-design.md @@ -0,0 +1,203 @@ +# Cross-server fact placeholder — design + +**Date:** 2026-06-27 +**Status:** Approved (design), pending implementation plan + +## Problem + +On a Typewriter network, a fact's **value** already follows the player across servers (Inkwell loads it +into Typewriter's fact cache on pre-login, on every server). But a fact can only be *read/displayed* +through a `ReadableFactEntry` defined in that server's loaded content (pages). A hub/lobby that does not +run the floor's quest content has no such entry, so `/tw facts query ` answers +`Could not find entry`, even though the value is sitting in the hub's cache. + +The user wants a floor fact (e.g. `p1_p01_kill_objective_statut`) to be **displayable on the hub** for +players (scoreboard / hologram / TAB) **without copying or merging Typewriter pages between servers**. + +### Confirmed constraints (verified against Typewriter `engine-paper:0.9.0` bytecode) + +- `FactEntry.identifier(player)` builds `FactId(entry.id, GroupId(player.uuid))`. The storage key is the + entry's generated **id**, never its name. Inkwell stores facts as `|`. +- `/tw facts query ` resolves the entry by **name** via `Query.findWhere(ReadableFactEntry::class)`, + then `readForPlayersGroup(player)`. With no matching entry in loaded content → "Could not find entry". +- Therefore a server that lacks the entry knows neither the entry's **id** (so it cannot index the cache) + nor the **name→id** mapping. That mapping must be supplied out-of-band. + +## Goal / non-goals + +**Goal:** Expose a persisted fact's value on any server — including servers without the defining entry — +as a PlaceholderAPI placeholder, for the **online player being rendered**, with no page duplication. + +**Non-goals (explicitly out of scope for this work):** + +- Reading values for **offline players** or **other players** (leaderboards). Requires a Mongo read per + lookup; not included. The placeholder serves the rendered (online) player only. +- Making Typewriter itself "know" the fact on the hub (conditions, dialogue, audiences). No synthetic + entry injection into Typewriter's entry registry. +- A chat command. Placeholder only. +- Shading PlaceholderAPI (it is a runtime-provided plugin). + +## Key insight + +For the **online player being rendered**, the value is already in the local Typewriter fact cache +(Inkwell's `FactSessionSync.loadInto` puts *all* of the player's persisted facts into the cache on +pre-login, regardless of whether the entry is defined on this server). So the value source at render time +is the in-memory cache — **no Mongo I/O per placeholder render**. The only missing piece on a +content-less server is the **name → entryId** mapping, which we sync through Mongo as a small catalog. + +## Architecture + +Two independent concerns, mirroring Inkwell's existing modular storage helpers +(`MongoPageSync`, `MongoFileSync`, `FactSessionSync`): + +``` +Floor server (has entries) Hub server (no entries) +────────────────────────── ─────────────────────── +FactCatalogPublisher catalog cache (name→id), refreshed periodically + scan Query.find from fact_catalog collection + → {entryId → name} InkwellPlaceholderExpansion + → MongoFactCatalog.publish (UPSERT-ONLY) %inkwell_fact_% + │ name → entryId (catalog cache) + ▼ → value from Typewriter fact cache (reflection) + Mongo `fact_catalog` ───────────────────► → render + { _id: entryId, name } +``` + +Every server runs **both** roles (symmetric). A server publishes whatever persistable fact entries it has +(none/few on a hub — harmless) and consumes the union for its placeholders. The floor server's own +placeholders work the same way. + +## Components + +### 1. `MongoFactCatalog` (new — `storage/`) + +Collection `fact_catalog`, one document per fact entry: `{ _id: , name: }`. + +- `suspend fun publish(idToName: Map)` — bulk **upsert-only**, never deletes. Empty map + is a no-op. **Rationale:** identical to the facts/pages safety rule — a server that lacks an entry must + never prune it from the shared catalog, or it would erase other servers' entries (the page-pruning + hazard, avoided here by design). +- `suspend fun loadAll(): Map` — returns **name → entryId**. On a duplicate name (should + not happen for distinct entries), last wins; log at most once. +- `data class CatalogDocument(@BsonId val id: String, val name: String)`. + +### 2. `FactCatalogPublisher` (new) + +Builds `{ entry.id → entry.name }` from `Query.find()` and calls +`MongoFactCatalog.publish`. + +- **Why `PersistableFactEntry` (not all `ReadableFactEntry`)**: only persisted facts travel via Inkwell + and land in the hub's cache, so only those can ever render a real value. Publishing non-persistable + (dynamic/computed) facts would advertise names the hub can never resolve to a value. +- **Change detection:** keep the last published map in memory; skip the Mongo write when unchanged + (entries change rarely — idle cycles are free). +- **Triggers:** (a) a periodic task every `catalog_refresh_seconds` (self-healing — covers the startup + race where Typewriter has not finished loading content yet, since an empty scan simply no-ops and the + next tick succeeds); (b) immediately on `StagingChangeEvent` (PUBLISHED) so edits propagate at once. +- Mongo write runs off the main thread (async task → `runBlocking`), like the existing fact sync. + +### 3. Consumer-side catalog cache + +An in-memory `@Volatile` `Map` (name → entryId) on each server. + +- Loaded at enable and refreshed every `catalog_refresh_seconds` (async). +- On Mongo failure, the previous map is retained (no flapping). + +### 4. `InkwellPlaceholderExpansion` (new — extends PlaceholderAPI `PlaceholderExpansion`) + +- Identity: `getIdentifier() = "inkwell"`, version from plugin, `persist() = true` (survives PAPI reload). +- `onPlaceholderRequest(player: Player?, params: String): String?` + 1. `player == null` → return the configured default. + 2. `params` must match `fact_`; otherwise return `null` (not ours). `name` is the full remainder + after `fact_` (names contain underscores — take everything). + 3. `entryId = catalogCache[name]` — if absent, return the default. + 4. `value = factCache lookup for (entryId, player.uniqueId)` — if absent, return the default. + 5. return `value.toString()`. +- Registered in `onEnable` only when `storage.fact_catalog` is enabled **and** PlaceholderAPI is present. + +### 5. Shared fact-cache accessor (small refactor) + +`FactSessionSync` already reaches `FactDatabase.cache` by guarded reflection. Extract that into a shared +helper (e.g. `FactCacheAccess`) exposing: + +- `reflect(logger): MutableMap?` (the existing logic, moved). +- `read(cache, entryId, uuid): Int?` — find the value by scanning for + `key.entryId == entryId && key.groupId.id == uuid.toString()`. Cache is small (online players only); an + O(n) scan per render is negligible (an O(1) keyed lookup is a possible later optimisation but is awkward + because `FactId`'s constructor is private). `FactSessionSync` keeps its current behaviour, now calling + the shared helper, so existing tests stay green. + +### 6. Config (`DatabaseConfig` + `config.yml`) + +```yaml +database: + catalog_collection: "fact_catalog" # name↔id catalog for cross-server fact display +storage: + fact_catalog: true # publish the catalog + register the %inkwell_fact_…% placeholder + catalog_refresh_seconds: 300 # how often each server reloads the name→id map (and republishes) + fact_placeholder_default: "0" # value returned when the fact is unknown/unset +``` + +New `DatabaseConfig` fields: `catalogCollection`, `persistFactCatalog`, `catalogRefreshSeconds`, +`factPlaceholderDefault`. Defaults preserve current behaviour when the keys are absent. + +**Dependency note:** the placeholder reads the fact cache populated by fact persistence, so the consuming +server must also run with `storage.facts: true`. Documented in README/config comments. + +### 7. Build & manifest + +- `build.gradle.kts`: add repo `https://repo.extendedclip.com/releases/` and + `compileOnly("me.clip:placeholderapi:2.11.6")`. Not shaded (runtime-provided). +- `plugin.yml`: add `softdepend: [PlaceholderAPI]` (keep `depend: [Typewriter]`). + +## Data flow (end to end) + +1. **Floor server** periodically / on publish: scans its persistable fact entries → upserts + `{id, name}` into `fact_catalog` (union grows; nothing pruned). It also writes fact **values** + (existing behaviour). +2. **Hub** at startup + every `catalog_refresh_seconds`: loads name→id map from `fact_catalog`. +3. **Player joins hub**: `FactSessionSync.loadInto` loads all their persisted facts into the local cache. +4. **Scoreboard/hologram renders** `%inkwell_fact_p1_p01_kill_objective_statut%` for that player: + name → entryId (memory map) → value (local cache) → `"1"`. No Mongo at render. + +## Error handling + +- **Mongo unreachable:** publisher logs and skips; consumer keeps its last map; placeholder returns the + default. Consistent with Inkwell's existing "log and continue, no retry" stance. +- **PlaceholderAPI absent:** expansion not registered; a single info/warning line at enable. +- **Reflection blocked** (future Typewriter renames `FactDatabase.cache`): the shared accessor returns + `null`; placeholder returns the default; the existing severe-log guard fires once. Cross-server facts + and the placeholder degrade together, neither crashes. +- **Unknown name / unset value:** the configured default (`"0"`). + +## Testing + +- `MongoFactCatalog` (Testcontainers): `publish` is upsert-only — two publishes with **disjoint** id sets + leave the **union** present (proves no cross-server pruning); `loadAll` returns name→id. +- `FactCatalogPublisher`: builds the map from a stubbed entry set; change-detection skips an unchanged + republish; empty scan is a no-op. +- Placeholder resolution (pure logic over a fake catalog map + fake cache): known name → value; + unknown name → default; unset value → default; `null` player → default; `params` not starting with + `fact_` → `null`. +- Existing `FactSessionSyncTest` / `DbFactStorageTest` / `MongoPageSyncTest` stay green after the + fact-cache accessor extraction. + +## Files touched + +New: +- `src/main/kotlin/fr/perrier/inkwell/storage/MongoFactCatalog.kt` +- `src/main/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisher.kt` +- `src/main/kotlin/fr/perrier/inkwell/storage/FactCacheAccess.kt` (extracted reflection + read helper) +- `src/main/kotlin/fr/perrier/inkwell/InkwellPlaceholderExpansion.kt` +- tests for the above + +Changed: +- `config/DatabaseConfig.kt` (new fields) +- `InkwellModule.kt` (provide `MongoFactCatalog` when enabled) +- `InkwellPlugin.kt` (wire publisher + consumer refresh + register expansion; reuse `StagingPublishListener` + or add a catalog trigger) +- `FactSessionSync.kt` (use the shared `FactCacheAccess`) +- `src/main/resources/config.yml`, `src/main/resources/plugin.yml` +- `build.gradle.kts` +- `README.md` (document the placeholder + the `facts: true` dependency) +``` From 4e53924b16cecfbff6e40924c3bed3e5ddf74536 Mon Sep 17 00:00:00 2001 From: chikage Date: Sat, 27 Jun 2026 23:19:59 +0200 Subject: [PATCH 05/12] docs: implementation plan for cross-server fact placeholder Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DSbnaoxxpzqfrNexWsaVj5 --- ...026-06-27-cross-server-fact-placeholder.md | 924 ++++++++++++++++++ 1 file changed, 924 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-27-cross-server-fact-placeholder.md diff --git a/docs/superpowers/plans/2026-06-27-cross-server-fact-placeholder.md b/docs/superpowers/plans/2026-06-27-cross-server-fact-placeholder.md new file mode 100644 index 0000000..f2de03a --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-cross-server-fact-placeholder.md @@ -0,0 +1,924 @@ +# Cross-server fact placeholder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose a persisted Typewriter fact's value on any server — including servers without the defining page — as a PlaceholderAPI placeholder `%inkwell_fact_%`, with no page duplication. + +**Architecture:** A small Mongo-synced catalog maps each fact entry's generated `id` to its human `name` (upsert-only, never pruned). Servers that have the entries publish the catalog; any server resolves `name → id` from it, then reads the value from Typewriter's already-loaded in-memory fact cache (Inkwell loads every connecting player's facts there regardless of whether the entry is defined locally). No Mongo I/O at render time. + +**Tech Stack:** Kotlin 2.3.20, JDK 21, Gradle, MongoDB Kotlin Coroutine driver 5.2, Typewriter engine-paper/engine-core 0.9.0, PlaceholderAPI 2.11.6 (compileOnly), JUnit 5 + MockK + Testcontainers. + +## Global Constraints + +- Package root: `fr.perrier.inkwell`. Match existing file style (KDoc on classes, `runCatching { … }.onFailure { logger.warning(...) }`). +- **Upsert-only rule:** any Mongo write that a content-less server may perform MUST NOT delete documents it doesn't know about (a hub must never prune another server's catalog/fact data). Mirrors `MongoFactStorage.storeFacts`. +- PlaceholderAPI is a runtime-provided plugin: `compileOnly` dependency, `softdepend` in `plugin.yml`, never shaded. +- Do NOT shade the Kotlin runtime / coroutines-core / serialization (already excluded in `build.gradle.kts`). Mongo + bson stay shaded & relocated. +- Mongo I/O is `suspend` + `withContext(IO)`; main-thread Bukkit tasks must offload Mongo to async tasks. +- The placeholder consuming server must run with `storage.facts: true` (its value source is the fact cache populated by fact persistence). +- New `DatabaseConfig` fields MUST have Kotlin default values so the existing `DbFactStorageTest` constructor call keeps compiling. + +--- + +### Task 1: Foundation — dependency, config fields, manifest + +**Files:** +- Modify: `build.gradle.kts` (repositories + dependencies) +- Modify: `src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt` +- Modify: `src/main/resources/config.yml` +- Modify: `src/main/resources/plugin.yml` + +**Interfaces:** +- Produces: `DatabaseConfig.catalogCollection: String`, `DatabaseConfig.persistFactCatalog: Boolean`, `DatabaseConfig.catalogRefreshSeconds: Long`, `DatabaseConfig.factPlaceholderDefault: String`. + +- [ ] **Step 1: Add the PlaceholderAPI repo + dependency** + +In `build.gradle.kts`, add the repository inside the `repositories { … }` block: + +```kotlin + maven("https://repo.extendedclip.com/releases/") { name = "placeholderapi" } +``` + +And in `dependencies { … }`, next to the other `compileOnly` Paper/Typewriter lines: + +```kotlin + compileOnly("me.clip:placeholderapi:2.11.6") +``` + +- [ ] **Step 2: Add the config fields** + +In `DatabaseConfig.kt`, add four fields to the data class (after `authSource`, all with defaults): + +```kotlin + val authSource: String? = null, + // Cross-server fact display. The catalog maps each fact entry's generated id to its name so a + // server without the defining page can resolve a fact by name. Upsert-only, network-shared. + val catalogCollection: String = "fact_catalog", + val persistFactCatalog: Boolean = true, + val catalogRefreshSeconds: Long = 300, + val factPlaceholderDefault: String = "0", +) { +``` + +In the `from()` builder, add the parsing lines (after `authSource = …`): + +```kotlin + catalogCollection = section.getString("catalog_collection") ?: "fact_catalog", + persistFactCatalog = storage?.getBoolean("fact_catalog", true) ?: true, + catalogRefreshSeconds = storage?.getLong("catalog_refresh_seconds", 300L) ?: 300L, + factPlaceholderDefault = storage?.getString("fact_placeholder_default") ?: "0", +``` + +- [ ] **Step 3: Document the keys in `config.yml`** + +In `src/main/resources/config.yml`, under `database:` add (after `files_collection`): + +```yaml + # Collection mapping each fact entry's id to its name, so a server without a page can still resolve a + # fact by name for display (PlaceholderAPI). Upsert-only and shared across the network. + catalog_collection: "fact_catalog" +``` + +Under `storage:` add (after `snippets: true`): + +```yaml + # Publish this server's fact-entry names to the catalog AND register the %inkwell_fact_% + # placeholder. The consuming server must also have facts: true (the value comes from the fact cache). + fact_catalog: true + # How often (seconds) each server republishes its catalog and reloads the name->id map. + catalog_refresh_seconds: 300 + # Value returned by %inkwell_fact_% when the fact is unknown on the network or unset for the player. + fact_placeholder_default: "0" +``` + +- [ ] **Step 4: Add the soft dependency** + +In `src/main/resources/plugin.yml`, after the `depend:` block add: + +```yaml +softdepend: + - PlaceholderAPI +``` + +- [ ] **Step 5: Verify it compiles** + +Run: `./gradlew compileKotlin compileTestKotlin` +Expected: `BUILD SUCCESSFUL` (the new fields have defaults, so existing code/tests compile unchanged). + +- [ ] **Step 6: Commit** + +```bash +git add build.gradle.kts src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt src/main/resources/config.yml src/main/resources/plugin.yml +git commit -m "feat: add fact-catalog config + PlaceholderAPI dependency" +``` + +--- + +### Task 2: `MongoFactCatalog` storage + +**Files:** +- Create: `src/main/kotlin/fr/perrier/inkwell/storage/MongoFactCatalog.kt` +- Test: `src/test/kotlin/fr/perrier/inkwell/storage/MongoFactCatalogTest.kt` + +**Interfaces:** +- Consumes: `DatabaseConfig.catalogCollection` (Task 1). +- Produces: + - `class MongoFactCatalog(database: MongoDatabase, config: DatabaseConfig, logger: Logger)` + - `suspend fun publish(idToName: Map)` — upsert-only, empty = no-op. + - `suspend fun loadAll(): Map` — returns **name → entryId**. + - `data class CatalogDocument(@BsonId val id: String, val name: String)` + +- [ ] **Step 1: Write the failing test** + +Create `src/test/kotlin/fr/perrier/inkwell/storage/MongoFactCatalogTest.kt`: + +```kotlin +package fr.perrier.inkwell.storage + +import com.mongodb.kotlin.client.coroutine.MongoClient +import fr.perrier.inkwell.config.DatabaseConfig +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.testcontainers.containers.MongoDBContainer +import org.testcontainers.utility.DockerImageName +import java.util.logging.Logger + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MongoFactCatalogTest { + + private val mongo = MongoDBContainer(DockerImageName.parse("mongo:7")) + private lateinit var client: MongoClient + private lateinit var catalog: MongoFactCatalog + + @BeforeAll + fun setUp() { + mongo.start() + val config = DatabaseConfig( + uri = mongo.replicaSetUrl, + databaseName = "test", + factsCollection = "facts", + pagesCollection = "pages", + filesCollection = "files", + typewriterFolder = "Typewriter", + timeoutMs = 5000, + persistFacts = true, + persistPages = true, + persistSnippets = true, + factSyncIntervalSeconds = 3, + catalogCollection = "fact_catalog", + ) + client = MongoClient.create(config.uri) + catalog = MongoFactCatalog( + client.getDatabase(config.databaseName), + config, + Logger.getLogger("MongoFactCatalogTest"), + ) + } + + @AfterAll + fun tearDown() { + client.close() + mongo.stop() + } + + @Test + fun `publish is upsert-only and unions across servers`() = runTest { + catalog.publish(mapOf("id_a" to "fact_a", "id_b" to "fact_b")) + // A second server that only knows id_b must NOT prune id_a from the shared catalog. + catalog.publish(mapOf("id_b" to "fact_b")) + + val all = catalog.loadAll() + assertEquals("id_a", all["fact_a"]) + assertEquals("id_b", all["fact_b"]) + } + + @Test + fun `loadAll maps name to id`() = runTest { + catalog.publish(mapOf("xyz123" to "quest_stage")) + assertEquals("xyz123", catalog.loadAll()["quest_stage"]) + } + + @Test + fun `publish of an empty map is a no-op`() = runTest { + catalog.publish(emptyMap()) + assertNull(catalog.loadAll()["never_published_name"]) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./gradlew test --tests "fr.perrier.inkwell.storage.MongoFactCatalogTest"` +Expected: FAIL — compilation error, `MongoFactCatalog` does not exist. + +- [ ] **Step 3: Implement `MongoFactCatalog`** + +Create `src/main/kotlin/fr/perrier/inkwell/storage/MongoFactCatalog.kt`: + +```kotlin +package fr.perrier.inkwell.storage + +import com.mongodb.client.model.Filters +import com.mongodb.client.model.ReplaceOneModel +import com.mongodb.client.model.ReplaceOptions +import com.mongodb.kotlin.client.coroutine.MongoCollection +import com.mongodb.kotlin.client.coroutine.MongoDatabase +import fr.perrier.inkwell.config.DatabaseConfig +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.withContext +import org.bson.codecs.pojo.annotations.BsonId +import java.util.logging.Logger + +/** + * A network-shared catalog mapping each fact entry's generated id to its human name, so a server that + * doesn't define an entry (e.g. a hub) can still resolve a fact by name for display. One document per + * entry: `{ _id: , name: }`. + * + * [publish] is UPSERT-ONLY and never deletes: a server that lacks an entry must not prune it from the + * shared catalog — exactly like [MongoFactStorage.storeFacts] never wipes other servers' facts. + */ +class MongoFactCatalog( + private val database: MongoDatabase, + private val config: DatabaseConfig, + private val logger: Logger, +) { + private val collection: MongoCollection by lazy { + database.getCollection(config.catalogCollection, CatalogDocument::class.java) + } + + suspend fun publish(idToName: Map) { + if (idToName.isEmpty()) return + withContext(IO) { + runCatching { + collection.bulkWrite( + idToName.map { (id, name) -> + ReplaceOneModel( + Filters.eq("_id", id), + CatalogDocument(id, name), + ReplaceOptions().upsert(true), + ) + }, + ) + }.onFailure { logger.warning("Mongo fact-catalog publish failed: ${it.message}") } + } + } + + suspend fun loadAll(): Map = withContext(IO) { + runCatching { + collection.find().toList().associate { it.name to it.id } + }.getOrElse { + logger.warning("Mongo fact-catalog loadAll failed: ${it.message}") + emptyMap() + } + } + + data class CatalogDocument( + @BsonId val id: String, + val name: String, + ) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `./gradlew test --tests "fr.perrier.inkwell.storage.MongoFactCatalogTest"` +Expected: PASS (3 tests). Requires Docker for Testcontainers. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/kotlin/fr/perrier/inkwell/storage/MongoFactCatalog.kt src/test/kotlin/fr/perrier/inkwell/storage/MongoFactCatalogTest.kt +git commit -m "feat: add MongoFactCatalog (upsert-only name<->id store)" +``` + +--- + +### Task 3: `FactCatalogPublisher` + +**Files:** +- Create: `src/main/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisher.kt` +- Test: `src/test/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisherTest.kt` + +**Interfaces:** +- Consumes: `MongoFactCatalog.publish` (Task 2). +- Produces: + - `class FactCatalogPublisher(catalog: MongoFactCatalog, logger: Logger, entriesProvider: () -> Map = ::scanPersistableFacts)` + - `fun publish()` — scans (via `entriesProvider`), publishes only when the map is non-empty and changed since the last publish. + - Default scan uses `Query.find().associate { it.id to it.name }`. + +- [ ] **Step 1: Write the failing test** + +Create `src/test/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisherTest.kt`: + +```kotlin +package fr.perrier.inkwell.storage + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.logging.Logger + +/** Unit tests for [FactCatalogPublisher] with the Mongo catalog mocked (no Docker needed). */ +class FactCatalogPublisherTest { + + private val logger = Logger.getLogger("FactCatalogPublisherTest") + + @Test + fun `publish sends the scanned entries to the catalog`() { + val catalog = mockk() + val captured = slot>() + coEvery { catalog.publish(capture(captured)) } returns Unit + + val publisher = FactCatalogPublisher(catalog, logger) { mapOf("id1" to "name1") } + publisher.publish() + + coVerify(exactly = 1) { catalog.publish(any()) } + assertEquals("name1", captured.captured["id1"]) + } + + @Test + fun `publish skips when nothing changed since last time`() { + val catalog = mockk() + coEvery { catalog.publish(any()) } returns Unit + + val publisher = FactCatalogPublisher(catalog, logger) { mapOf("id1" to "name1") } + publisher.publish() + publisher.publish() + + coVerify(exactly = 1) { catalog.publish(any()) } + } + + @Test + fun `publish skips an empty scan (content not loaded yet)`() { + val catalog = mockk() + coEvery { catalog.publish(any()) } returns Unit + + val publisher = FactCatalogPublisher(catalog, logger) { emptyMap() } + publisher.publish() + + coVerify(exactly = 0) { catalog.publish(any()) } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./gradlew test --tests "fr.perrier.inkwell.storage.FactCatalogPublisherTest"` +Expected: FAIL — `FactCatalogPublisher` does not exist. + +- [ ] **Step 3: Implement `FactCatalogPublisher`** + +Create `src/main/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisher.kt`: + +```kotlin +package fr.perrier.inkwell.storage + +import com.typewritermc.core.entries.Query +import com.typewritermc.engine.paper.entry.entries.PersistableFactEntry +import kotlinx.coroutines.runBlocking +import java.util.logging.Logger + +/** + * Publishes this server's persistable fact entries (`id -> name`) to the shared [MongoFactCatalog] so + * other servers can resolve those facts by name for display. Only **persistable** facts are published — + * they are the only ones whose values travel via Inkwell and land in another server's fact cache. + * + * [entriesProvider] is injectable for testing; by default it scans Typewriter's loaded content. Publishes + * only when the scanned set is non-empty (skips the startup race where content isn't loaded yet) and has + * changed since the last publish (idle cycles are free). + */ +class FactCatalogPublisher( + private val catalog: MongoFactCatalog, + private val logger: Logger, + private val entriesProvider: () -> Map = ::scanPersistableFacts, +) { + @Volatile + private var lastPublished: Map = emptyMap() + + fun publish() { + val current = runCatching(entriesProvider).getOrElse { + logger.warning("Fact-catalog scan failed: ${it.message}") + return + } + if (current.isEmpty() || current == lastPublished) return + lastPublished = current + runCatching { runBlocking { catalog.publish(current) } } + .onFailure { logger.warning("Fact-catalog publish failed: ${it.message}") } + } + + companion object { + private fun scanPersistableFacts(): Map = + Query.find().associate { it.id to it.name } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `./gradlew test --tests "fr.perrier.inkwell.storage.FactCatalogPublisherTest"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisher.kt src/test/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisherTest.kt +git commit -m "feat: add FactCatalogPublisher (scan persistable facts -> catalog)" +``` + +--- + +### Task 4: `FactCacheAccess` (extract reflection + keyed read) + +**Files:** +- Create: `src/main/kotlin/fr/perrier/inkwell/storage/FactCacheAccess.kt` +- Modify: `src/main/kotlin/fr/perrier/inkwell/storage/FactSessionSync.kt` (use the shared helper) +- Test: `src/test/kotlin/fr/perrier/inkwell/storage/FactCacheAccessTest.kt` + +**Interfaces:** +- Produces: + - `object FactCacheAccess` + - `fun reflect(logger: Logger): MutableMap?` + - `fun read(cache: Map, entryId: String, uuid: UUID): Int?` — O(1) keyed lookup. +- Consumes: nothing new (moves existing reflection out of `FactSessionSync`). + +- [ ] **Step 1: Write the failing test** + +Create `src/test/kotlin/fr/perrier/inkwell/storage/FactCacheAccessTest.kt`: + +```kotlin +package fr.perrier.inkwell.storage + +import com.typewritermc.engine.paper.entry.entries.GroupId +import com.typewritermc.engine.paper.facts.FactData +import com.typewritermc.engine.paper.facts.FactId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.UUID + +/** Pure tests for the keyed cache read — no Typewriter runtime / reflection involved. */ +class FactCacheAccessTest { + + private val now: LocalDateTime = LocalDateTime.parse("2026-05-23T10:30:00") + + @Test + fun `read returns the value for a matching entry id and player`() { + val uuid = UUID.randomUUID() + val cache = hashMapOf( + FactId("entry1", GroupId(uuid)) to FactData(5, now), + ) + assertEquals(5, FactCacheAccess.read(cache, "entry1", uuid)) + } + + @Test + fun `read returns null when the fact is absent`() { + val uuid = UUID.randomUUID() + val cache = hashMapOf() + assertNull(FactCacheAccess.read(cache, "missing", uuid)) + } + + @Test + fun `read returns null for a different player`() { + val mine = UUID.randomUUID() + val other = UUID.randomUUID() + val cache = hashMapOf( + FactId("entry1", GroupId(other)) to FactData(9, now), + ) + assertNull(FactCacheAccess.read(cache, "entry1", mine)) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./gradlew test --tests "fr.perrier.inkwell.storage.FactCacheAccessTest"` +Expected: FAIL — `FactCacheAccess` does not exist. + +- [ ] **Step 3: Implement `FactCacheAccess`** + +Create `src/main/kotlin/fr/perrier/inkwell/storage/FactCacheAccess.kt`: + +```kotlin +package fr.perrier.inkwell.storage + +import com.typewritermc.engine.paper.entry.entries.GroupId +import com.typewritermc.engine.paper.facts.FactData +import com.typewritermc.engine.paper.facts.FactDatabase +import com.typewritermc.engine.paper.facts.FactId +import org.koin.core.context.GlobalContext +import java.util.UUID +import java.util.logging.Logger + +/** + * Shared access to Typewriter's private [FactDatabase] fact cache. Typewriter exposes no per-player hook, + * so we reach the cache by guarded reflection — used both by [FactSessionSync] (cross-server sync) and by + * the `%inkwell_fact_%` placeholder (read-only display). + */ +object FactCacheAccess { + + @Suppress("UNCHECKED_CAST") + fun reflect(logger: Logger): MutableMap? = + runCatching { + val factDatabase = GlobalContext.get().get() + val field = FactDatabase::class.java.getDeclaredField("cache").apply { isAccessible = true } + field.get(factDatabase) as MutableMap + }.onFailure { + logger.severe("Could not access FactDatabase cache via reflection — cross-server facts disabled: ${it.message}") + }.getOrNull() + + /** Read one player's value for [entryId] from the loaded cache, or null if absent. O(1) keyed lookup. */ + fun read(cache: Map, entryId: String, uuid: UUID): Int? = + cache[FactId(entryId, GroupId(uuid))]?.value +} +``` + +- [ ] **Step 4: Point `FactSessionSync` at the shared helper** + +In `FactSessionSync.kt`, change the constructor default (line ~33) from: + +```kotlin + cacheProvider: () -> MutableMap? = { reflectFactCache(logger) }, +``` + +to: + +```kotlin + cacheProvider: () -> MutableMap? = { FactCacheAccess.reflect(logger) }, +``` + +Then delete the now-unused `companion object { … reflectFactCache … }` block at the bottom of the file (the whole `companion object` and its `reflectFactCache` function), and remove the now-unused imports it required: `com.typewritermc.engine.paper.facts.FactDatabase` and `org.koin.core.context.GlobalContext`. + +- [ ] **Step 5: Run the affected tests to verify nothing regressed** + +Run: `./gradlew test --tests "fr.perrier.inkwell.storage.FactCacheAccessTest" --tests "fr.perrier.inkwell.storage.FactSessionSyncTest"` +Expected: PASS — `FactCacheAccessTest` (3 tests) green, and `FactSessionSyncTest` (2 tests) still green (it injects its own `cacheProvider`, so the default change doesn't affect it). + +- [ ] **Step 6: Commit** + +```bash +git add src/main/kotlin/fr/perrier/inkwell/storage/FactCacheAccess.kt src/main/kotlin/fr/perrier/inkwell/storage/FactSessionSync.kt src/test/kotlin/fr/perrier/inkwell/storage/FactCacheAccessTest.kt +git commit -m "refactor: extract FactCacheAccess (shared reflection + keyed read)" +``` + +--- + +### Task 5: `FactPlaceholderResolver` + PlaceholderAPI adapter + +**Files:** +- Create: `src/main/kotlin/fr/perrier/inkwell/FactPlaceholderResolver.kt` +- Create: `src/main/kotlin/fr/perrier/inkwell/InkwellPlaceholderExpansion.kt` +- Test: `src/test/kotlin/fr/perrier/inkwell/FactPlaceholderResolverTest.kt` + +**Interfaces:** +- Consumes: `FactCacheAccess.read` (Task 4). +- Produces: + - `class FactPlaceholderResolver(catalogProvider: () -> Map, cacheProvider: () -> Map?, default: String)` + - `fun resolve(params: String, uuid: UUID?): String?` — `null` when `params` isn't a `fact_` placeholder; otherwise the value or `default`. + - `companion object { const val PREFIX = "fact_" }` + - `class InkwellPlaceholderExpansion(resolver: FactPlaceholderResolver, version: String) : PlaceholderExpansion` with `getIdentifier() = "inkwell"`. + +- [ ] **Step 1: Write the failing test** + +Create `src/test/kotlin/fr/perrier/inkwell/FactPlaceholderResolverTest.kt`: + +```kotlin +package fr.perrier.inkwell + +import com.typewritermc.engine.paper.entry.entries.GroupId +import com.typewritermc.engine.paper.facts.FactData +import com.typewritermc.engine.paper.facts.FactId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.UUID + +class FactPlaceholderResolverTest { + + private val now: LocalDateTime = LocalDateTime.parse("2026-05-23T10:30:00") + private val uuid: UUID = UUID.randomUUID() + + private fun resolver( + catalog: Map, + cache: Map?, + ) = FactPlaceholderResolver( + catalogProvider = { catalog }, + cacheProvider = { cache }, + default = "0", + ) + + @Test + fun `resolves a known fact to its value`() { + val r = resolver( + catalog = mapOf("p1_kill" to "entry1"), + cache = mapOf(FactId("entry1", GroupId(uuid)) to FactData(3, now)), + ) + assertEquals("3", r.resolve("fact_p1_kill", uuid)) + } + + @Test + fun `unknown name returns the default`() { + val r = resolver(catalog = emptyMap(), cache = emptyMap()) + assertEquals("0", r.resolve("fact_unknown", uuid)) + } + + @Test + fun `name in catalog but value unset returns the default`() { + val r = resolver(catalog = mapOf("p1_kill" to "entry1"), cache = emptyMap()) + assertEquals("0", r.resolve("fact_p1_kill", uuid)) + } + + @Test + fun `null player returns the default`() { + val r = resolver(catalog = mapOf("p1_kill" to "entry1"), cache = emptyMap()) + assertEquals("0", r.resolve("fact_p1_kill", null)) + } + + @Test + fun `params not starting with fact_ returns null`() { + val r = resolver(catalog = emptyMap(), cache = emptyMap()) + assertNull(r.resolve("something_else", uuid)) + } + + @Test + fun `null cache returns the default`() { + val r = resolver(catalog = mapOf("p1_kill" to "entry1"), cache = null) + assertEquals("0", r.resolve("fact_p1_kill", uuid)) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./gradlew test --tests "fr.perrier.inkwell.FactPlaceholderResolverTest"` +Expected: FAIL — `FactPlaceholderResolver` does not exist. + +- [ ] **Step 3: Implement `FactPlaceholderResolver`** + +Create `src/main/kotlin/fr/perrier/inkwell/FactPlaceholderResolver.kt`: + +```kotlin +package fr.perrier.inkwell + +import com.typewritermc.engine.paper.facts.FactData +import com.typewritermc.engine.paper.facts.FactId +import fr.perrier.inkwell.storage.FactCacheAccess +import java.util.UUID + +/** + * Pure resolution logic behind `%inkwell_fact_%`: name -> entryId (via the synced catalog) -> + * value (from Typewriter's already-loaded fact cache). Returns [default] for an unknown name, an unset + * value, or no player; returns `null` when [params] isn't a `fact_` placeholder, so PlaceholderAPI keeps + * looking. No Mongo I/O here — both inputs are provided by the caller. + */ +class FactPlaceholderResolver( + private val catalogProvider: () -> Map, + private val cacheProvider: () -> Map?, + private val default: String, +) { + fun resolve(params: String, uuid: UUID?): String? { + if (!params.startsWith(PREFIX)) return null + if (uuid == null) return default + val name = params.removePrefix(PREFIX) + val entryId = catalogProvider()[name] ?: return default + val cache = cacheProvider() ?: return default + return (FactCacheAccess.read(cache, entryId, uuid) ?: return default).toString() + } + + companion object { + const val PREFIX = "fact_" + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `./gradlew test --tests "fr.perrier.inkwell.FactPlaceholderResolverTest"` +Expected: PASS (6 tests). + +- [ ] **Step 5: Implement the PlaceholderAPI adapter (glue, no unit test)** + +Create `src/main/kotlin/fr/perrier/inkwell/InkwellPlaceholderExpansion.kt`: + +```kotlin +package fr.perrier.inkwell + +import me.clip.placeholderapi.expansion.PlaceholderExpansion +import org.bukkit.OfflinePlayer + +/** + * Thin PlaceholderAPI adapter exposing `%inkwell_fact_%`. All logic lives in + * [FactPlaceholderResolver]; this class only bridges PlaceholderAPI's contract. `persist() = true` keeps + * it registered across `/papi reload` (it holds live references to Inkwell's in-memory state). + */ +class InkwellPlaceholderExpansion( + private val resolver: FactPlaceholderResolver, + private val version: String, +) : PlaceholderExpansion() { + + override fun getIdentifier(): String = "inkwell" + override fun getAuthor(): String = "PerrierBottle" + override fun getVersion(): String = version + override fun persist(): Boolean = true + + override fun onRequest(player: OfflinePlayer?, params: String): String? = + resolver.resolve(params, player?.uniqueId) +} +``` + +- [ ] **Step 6: Verify compilation (adapter has no test)** + +Run: `./gradlew compileKotlin` +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/kotlin/fr/perrier/inkwell/FactPlaceholderResolver.kt src/main/kotlin/fr/perrier/inkwell/InkwellPlaceholderExpansion.kt src/test/kotlin/fr/perrier/inkwell/FactPlaceholderResolverTest.kt +git commit -m "feat: add fact placeholder resolver + PlaceholderAPI expansion" +``` + +--- + +### Task 6: Wire into `InkwellPlugin` + README + +**Files:** +- Modify: `src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt` +- Modify: `README.md` + +**Interfaces:** +- Consumes: `MongoFactCatalog` (Task 2), `FactCatalogPublisher` (Task 3), `FactCacheAccess.reflect` (Task 4), `FactPlaceholderResolver` + `InkwellPlaceholderExpansion` (Task 5), all `DatabaseConfig` catalog fields (Task 1). + +- [ ] **Step 1: Add imports + fields to `InkwellPlugin`** + +In `InkwellPlugin.kt`, add these imports (next to the existing ones): + +```kotlin +import com.typewritermc.engine.paper.facts.FactData +import com.typewritermc.engine.paper.facts.FactId +import fr.perrier.inkwell.storage.FactCacheAccess +import fr.perrier.inkwell.storage.FactCatalogPublisher +import fr.perrier.inkwell.storage.MongoFactCatalog +``` + +Add fields next to the existing `private var factSync` etc.: + +```kotlin + private var catalogPublisher: FactCatalogPublisher? = null + + @Volatile + private var catalogMap: Map = emptyMap() + + // Reflected once on first placeholder use; the FactDatabase.cache reference is stable thereafter. + private val factCache: MutableMap? by lazy { FactCacheAccess.reflect(logger) } +``` + +- [ ] **Step 2: Start the catalog publisher + consumer + placeholder in `onEnable`** + +In `InkwellPlugin.kt`, replace the staging-listener registration at the top of `onEnable()`: + +```kotlin + // Push pages to Mongo whenever Typewriter publishes its staging state. + if (databaseConfig.persistPages || databaseConfig.persistSnippets) { + server.pluginManager.registerEvents(StagingPublishListener(::backupAll), this) + } +``` + +with a combined trigger that also republishes the catalog on content changes: + +```kotlin + // On a Typewriter publish: back up pages/snippets AND republish the fact catalog (content + // changes may add/rename fact entries). Each leg is independently gated and no-ops if disabled. + if (databaseConfig.persistPages || databaseConfig.persistSnippets || databaseConfig.persistFactCatalog) { + server.pluginManager.registerEvents( + StagingPublishListener { + backupAll() + catalogPublisher?.publish() + }, + this, + ) + } +``` + +Then, at the end of `onEnable()` (before `logger.info("Inkwell enabled")`), add: + +```kotlin + if (databaseConfig.persistFactCatalog) startFactCatalog() +``` + +- [ ] **Step 3: Add the `startFactCatalog` helper** + +In `InkwellPlugin.kt`, add this method (next to `schedulePeriodicFactSync`): + +```kotlin + /** + * Brings up the cross-server fact catalog: publishes this server's persistable fact entries (id->name) + * to Mongo on a timer + on publish, keeps a local name->id map refreshed, and registers the + * %inkwell_fact_% placeholder if PlaceholderAPI is installed. The placeholder reads values from + * Typewriter's already-loaded fact cache, so it needs no Mongo I/O per render. + */ + private fun startFactCatalog() { + val db = GlobalContext.get().get() + val catalog = MongoFactCatalog(db, databaseConfig, logger) + val publisher = FactCatalogPublisher(catalog, logger) + catalogPublisher = publisher + + val refreshTicks = (databaseConfig.catalogRefreshSeconds * 20L).coerceAtLeast(1L) + // Publish our entries (delayed start lets Typewriter finish loading content; an early empty scan + // simply no-ops and the next tick succeeds), then on the refresh cadence. + server.scheduler.runTaskTimerAsynchronously(this, Runnable { publisher.publish() }, 100L, refreshTicks) + // Keep the consumer-side name->id map fresh; on Mongo failure loadAll() returns empty, so guard it. + server.scheduler.runTaskTimerAsynchronously(this, Runnable { + runBlocking { catalog.loadAll() }.takeIf { it.isNotEmpty() }?.let { catalogMap = it } + }, 20L, refreshTicks) + + if (server.pluginManager.getPlugin("PlaceholderAPI") != null) { + val resolver = FactPlaceholderResolver({ catalogMap }, { factCache }, databaseConfig.factPlaceholderDefault) + InkwellPlaceholderExpansion(resolver, description.version).register() + logger.info("Registered PlaceholderAPI expansion 'inkwell' (%inkwell_fact_%)") + } else { + logger.info("PlaceholderAPI not found — %inkwell_fact_% placeholder disabled") + } + } +``` + +Note: `MongoDatabase` and `runBlocking` are already imported in `InkwellPlugin.kt`; `GlobalContext` too. + +- [ ] **Step 4: Document the feature in `README.md`** + +In `README.md`, under the `## Features` list add a bullet after the Snippets one: + +```markdown +- **Fact placeholders** → read any persisted fact on *any* server (even one without the defining page) + via PlaceholderAPI: `%inkwell_fact_%`. A small `fact_catalog` collection syncs each fact's + name→id; the value comes from the player's already-loaded fact cache (no per-render database hit). +``` + +And add a subsection after the `### Pages & snippets` section: + +```markdown +### Fact placeholders (cross-server display) + +`/tw facts query ` only works where the fact's **entry** is defined (i.e. its page is present), so a +hub that doesn't run a floor's quest content answers `Could not find entry` even though the value followed +the player. To display such facts without copying pages between servers, enable `storage.fact_catalog` and +use PlaceholderAPI: + +``` +%inkwell_fact_% e.g. %inkwell_fact_p1_p01_kill_objective_statut% +``` + +Servers that define the entries publish a `name → id` catalog to MongoDB (upsert-only — a server never +prunes entries it doesn't have). Any server resolves the name to an id from that catalog and reads the +value straight from the connecting player's loaded fact cache. The consuming server must also run with +`storage.facts: true`. Returns `fact_placeholder_default` (default `"0"`) when the fact is unknown +network-wide or unset for the player. The placeholder serves the **online player being rendered**; offline +players / other-player leaderboards are out of scope. +``` + +- [ ] **Step 5: Full build (compile + all tests + shaded jar)** + +Run: `./gradlew build` +Expected: `BUILD SUCCESSFUL` — all tests pass (existing + the new catalog/publisher/cache/resolver tests) and `shadowJar` produces `build/libs/inkwell-1.0.0-all.jar`. Requires Docker for the Testcontainers tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt README.md +git commit -m "feat: wire fact catalog publisher + %inkwell_fact% placeholder into the plugin" +``` + +--- + +## Self-Review + +**1. Spec coverage** + +| Spec item | Task | +|-----------|------| +| `MongoFactCatalog` (publish upsert-only, loadAll name→id, `CatalogDocument`) | Task 2 | +| `FactCatalogPublisher` (scan `PersistableFactEntry`, change-detection, periodic + StagingChangeEvent) | Task 3 (class) + Task 6 (triggers) | +| Consumer-side name→id cache refreshed every `catalog_refresh_seconds` | Task 6 (`startFactCatalog`, async refresh task) | +| `InkwellPlaceholderExpansion` (`%inkwell_fact_%`, default value) | Task 5 | +| Shared `FactCacheAccess` reflection + keyed read; `FactSessionSync` reuse | Task 4 | +| Config fields (`catalog_collection`, `fact_catalog`, `catalog_refresh_seconds`, `fact_placeholder_default`) | Task 1 | +| Build dep + `softdepend` PlaceholderAPI | Task 1 | +| README (placeholder + `facts: true` dependency) | Task 6 | +| Upsert-only / never-prune safety | Task 2 (`publish`) + Task 2 test | +| Value from cache, no Mongo at render | Task 5 (`resolve`) + Task 6 (`factCache` lazy) | + +No gaps. + +**2. Placeholder scan:** No "TBD"/"add error handling"/"similar to Task N". Every code step shows complete code; error paths use the codebase's `runCatching { … }.onFailure { logger.warning(...) }` idiom explicitly. + +**3. Type consistency:** `MongoFactCatalog.publish(Map)` / `loadAll(): Map` (name→id) used consistently by `FactCatalogPublisher` (Task 3) and the consumer map in Task 6. `FactCacheAccess.read(cache, entryId, uuid)` signature matches its use in `FactPlaceholderResolver` (Task 5). `FactPlaceholderResolver(catalogProvider, cacheProvider, default)` constructor matches its instantiation in `startFactCatalog` (Task 6). `catalogMap` (name→id) feeds `catalogProvider`; `factCache` (FactId→FactData) feeds `cacheProvider`. Consistent. From b55071a6e1649f9bd04a5637a92f646696c4633d Mon Sep 17 00:00:00 2001 From: chikage Date: Sat, 27 Jun 2026 23:19:59 +0200 Subject: [PATCH 06/12] feat: add fact-catalog config + PlaceholderAPI dependency Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DSbnaoxxpzqfrNexWsaVj5 --- build.gradle.kts | 2 ++ .../kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt | 10 ++++++++++ src/main/resources/config.yml | 10 ++++++++++ src/main/resources/plugin.yml | 2 ++ 4 files changed, 24 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 958ef1a..0e5229f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,7 @@ repositories { maven("https://repo.papermc.io/repository/maven-public/") { name = "papermc" } maven("https://maven.typewritermc.com/beta") { name = "typewriter-beta" } maven("https://maven.typewritermc.com/releases") { name = "typewriter-releases" } + maven("https://repo.extendedclip.com/releases/") { name = "placeholderapi" } } dependencies { @@ -19,6 +20,7 @@ dependencies { compileOnly("com.typewritermc:engine-paper:0.9.0") { isTransitive = false } // engine-core gives us Query (to filter persistable facts on player quit, like Typewriter does). compileOnly("com.typewritermc:engine-core:0.9.0") { isTransitive = false } + compileOnly("me.clip:placeholderapi:2.11.6") compileOnly("io.insert-koin:koin-core:3.5.6") compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3") diff --git a/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt b/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt index d3499e4..4f8e528 100644 --- a/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt +++ b/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt @@ -20,6 +20,12 @@ data class DatabaseConfig( val username: String? = null, val password: String? = null, val authSource: String? = null, + // Cross-server fact display. The catalog maps each fact entry's generated id to its name so a + // server without the defining page can resolve a fact by name. Upsert-only, network-shared. + val catalogCollection: String = "fact_catalog", + val persistFactCatalog: Boolean = true, + val catalogRefreshSeconds: Long = 300, + val factPlaceholderDefault: String = "0", ) { companion object { fun from(config: FileConfiguration): DatabaseConfig { @@ -34,6 +40,10 @@ data class DatabaseConfig( username = section.getString("username")?.takeIf { it.isNotBlank() }, password = section.getString("password")?.takeIf { it.isNotBlank() }, authSource = section.getString("auth_source")?.takeIf { it.isNotBlank() }, + catalogCollection = section.getString("catalog_collection") ?: "fact_catalog", + persistFactCatalog = storage?.getBoolean("fact_catalog", true) ?: true, + catalogRefreshSeconds = storage?.getLong("catalog_refresh_seconds", 300L) ?: 300L, + factPlaceholderDefault = storage?.getString("fact_placeholder_default") ?: "0", factsCollection = section.getString("facts_collection") ?: "facts", pagesCollection = section.getString("pages_collection") ?: "pages", filesCollection = section.getString("files_collection") ?: "files", diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 8a1295f..abd653f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -13,6 +13,9 @@ database: pages_collection: "pages" # Collection holding raw file blobs synced to/from disk (e.g. snippets.yml). files_collection: "files" + # Collection mapping each fact entry's id to its name, so a server without a page can still resolve a + # fact by name for display (PlaceholderAPI). Upsert-only and shared across the network. + catalog_collection: "fact_catalog" # Name of the Typewriter plugin data folder, sibling of this plugin's folder. typewriter_folder: "Typewriter" timeout_ms: 5000 @@ -26,6 +29,13 @@ storage: pages: true # snippets.yml configuration. snippets: true + # Publish this server's fact-entry names to the catalog AND register the %inkwell_fact_% + # placeholder. The consuming server must also have facts: true (the value comes from the fact cache). + fact_catalog: true + # How often (seconds) each server republishes its catalog and reloads the name->id map. + catalog_refresh_seconds: 300 + # Value returned by %inkwell_fact_% when the fact is unknown on the network or unset for the player. + fact_placeholder_default: "0" # How often (seconds) each online player's facts are reconciled to MongoDB. This is what makes # command-based changes (/tw facts set|add|reset) propagate across servers — gameplay changes are # already instant. A change is only written when something actually changed, so idle cycles are diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 9aa156b..dde3f7b 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -13,3 +13,5 @@ api-version: "1.21" # FactStorage in onEnableAsync(). depend: - Typewriter +softdepend: + - PlaceholderAPI From 5730a4425926573b43d9022de5a3c16dcb1c21b1 Mon Sep 17 00:00:00 2001 From: chikage Date: Sat, 27 Jun 2026 23:19:59 +0200 Subject: [PATCH 07/12] feat: add MongoFactCatalog (upsert-only name<->id store) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DSbnaoxxpzqfrNexWsaVj5 --- .../inkwell/storage/MongoFactCatalog.kt | 62 +++++++++++++++ .../inkwell/storage/MongoFactCatalogTest.kt | 76 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/main/kotlin/fr/perrier/inkwell/storage/MongoFactCatalog.kt create mode 100644 src/test/kotlin/fr/perrier/inkwell/storage/MongoFactCatalogTest.kt diff --git a/src/main/kotlin/fr/perrier/inkwell/storage/MongoFactCatalog.kt b/src/main/kotlin/fr/perrier/inkwell/storage/MongoFactCatalog.kt new file mode 100644 index 0000000..77c7312 --- /dev/null +++ b/src/main/kotlin/fr/perrier/inkwell/storage/MongoFactCatalog.kt @@ -0,0 +1,62 @@ +package fr.perrier.inkwell.storage + +import com.mongodb.client.model.Filters +import com.mongodb.client.model.ReplaceOneModel +import com.mongodb.client.model.ReplaceOptions +import com.mongodb.kotlin.client.coroutine.MongoCollection +import com.mongodb.kotlin.client.coroutine.MongoDatabase +import fr.perrier.inkwell.config.DatabaseConfig +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.withContext +import org.bson.codecs.pojo.annotations.BsonId +import java.util.logging.Logger + +/** + * A network-shared catalog mapping each fact entry's generated id to its human name, so a server that + * doesn't define an entry (e.g. a hub) can still resolve a fact by name for display. One document per + * entry: `{ _id: , name: }`. + * + * [publish] is UPSERT-ONLY and never deletes: a server that lacks an entry must not prune it from the + * shared catalog — exactly like [MongoFactStorage.storeFacts] never wipes other servers' facts. + */ +class MongoFactCatalog( + private val database: MongoDatabase, + private val config: DatabaseConfig, + private val logger: Logger, +) { + private val collection: MongoCollection by lazy { + database.getCollection(config.catalogCollection, CatalogDocument::class.java) + } + + suspend fun publish(idToName: Map) { + if (idToName.isEmpty()) return + withContext(IO) { + runCatching { + collection.bulkWrite( + idToName.map { (id, name) -> + ReplaceOneModel( + Filters.eq("_id", id), + CatalogDocument(id, name), + ReplaceOptions().upsert(true), + ) + }, + ) + }.onFailure { logger.warning("Mongo fact-catalog publish failed: ${it.message}") } + } + } + + suspend fun loadAll(): Map = withContext(IO) { + runCatching { + collection.find().toList().associate { it.name to it.id } + }.getOrElse { + logger.warning("Mongo fact-catalog loadAll failed: ${it.message}") + emptyMap() + } + } + + data class CatalogDocument( + @BsonId val id: String, + val name: String, + ) +} diff --git a/src/test/kotlin/fr/perrier/inkwell/storage/MongoFactCatalogTest.kt b/src/test/kotlin/fr/perrier/inkwell/storage/MongoFactCatalogTest.kt new file mode 100644 index 0000000..52c86a8 --- /dev/null +++ b/src/test/kotlin/fr/perrier/inkwell/storage/MongoFactCatalogTest.kt @@ -0,0 +1,76 @@ +package fr.perrier.inkwell.storage + +import com.mongodb.kotlin.client.coroutine.MongoClient +import fr.perrier.inkwell.config.DatabaseConfig +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.testcontainers.containers.MongoDBContainer +import org.testcontainers.utility.DockerImageName +import java.util.logging.Logger + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MongoFactCatalogTest { + + private val mongo = MongoDBContainer(DockerImageName.parse("mongo:7")) + private lateinit var client: MongoClient + private lateinit var catalog: MongoFactCatalog + + @BeforeAll + fun setUp() { + mongo.start() + val config = DatabaseConfig( + uri = mongo.replicaSetUrl, + databaseName = "test", + factsCollection = "facts", + pagesCollection = "pages", + filesCollection = "files", + typewriterFolder = "Typewriter", + timeoutMs = 5000, + persistFacts = true, + persistPages = true, + persistSnippets = true, + factSyncIntervalSeconds = 3, + catalogCollection = "fact_catalog", + ) + client = MongoClient.create(config.uri) + catalog = MongoFactCatalog( + client.getDatabase(config.databaseName), + config, + Logger.getLogger("MongoFactCatalogTest"), + ) + } + + @AfterAll + fun tearDown() { + client.close() + mongo.stop() + } + + @Test + fun `publish is upsert-only and unions across servers`() = runTest { + catalog.publish(mapOf("id_a" to "fact_a", "id_b" to "fact_b")) + // A second server that only knows id_b must NOT prune id_a from the shared catalog. + catalog.publish(mapOf("id_b" to "fact_b")) + + val all = catalog.loadAll() + assertEquals("id_a", all["fact_a"]) + assertEquals("id_b", all["fact_b"]) + } + + @Test + fun `loadAll maps name to id`() = runTest { + catalog.publish(mapOf("xyz123" to "quest_stage")) + assertEquals("xyz123", catalog.loadAll()["quest_stage"]) + } + + @Test + fun `publish of an empty map is a no-op`() = runTest { + catalog.publish(emptyMap()) + assertNull(catalog.loadAll()["never_published_name"]) + } +} From 7abd1c2eb9041daff5f2fbdcc8484c9f074ce325 Mon Sep 17 00:00:00 2001 From: chikage Date: Sat, 27 Jun 2026 23:20:00 +0200 Subject: [PATCH 08/12] feat: add FactCatalogPublisher (scan persistable facts -> catalog) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DSbnaoxxpzqfrNexWsaVj5 --- .../inkwell/storage/FactCatalogPublisher.kt | 40 +++++++++++++++ .../storage/FactCatalogPublisherTest.kt | 51 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/main/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisher.kt create mode 100644 src/test/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisherTest.kt diff --git a/src/main/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisher.kt b/src/main/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisher.kt new file mode 100644 index 0000000..af06e32 --- /dev/null +++ b/src/main/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisher.kt @@ -0,0 +1,40 @@ +package fr.perrier.inkwell.storage + +import com.typewritermc.core.entries.Query +import com.typewritermc.engine.paper.entry.entries.PersistableFactEntry +import kotlinx.coroutines.runBlocking +import java.util.logging.Logger + +/** + * Publishes this server's persistable fact entries (`id -> name`) to the shared [MongoFactCatalog] so + * other servers can resolve those facts by name for display. Only **persistable** facts are published — + * they are the only ones whose values travel via Inkwell and land in another server's fact cache. + * + * [entriesProvider] is injectable for testing; by default it scans Typewriter's loaded content. Publishes + * only when the scanned set is non-empty (skips the startup race where content isn't loaded yet) and has + * changed since the last publish (idle cycles are free). + */ +class FactCatalogPublisher( + private val catalog: MongoFactCatalog, + private val logger: Logger, + private val entriesProvider: () -> Map = ::scanPersistableFacts, +) { + @Volatile + private var lastPublished: Map = emptyMap() + + fun publish() { + val current = runCatching(entriesProvider).getOrElse { + logger.warning("Fact-catalog scan failed: ${it.message}") + return + } + if (current.isEmpty() || current == lastPublished) return + lastPublished = current + runCatching { runBlocking { catalog.publish(current) } } + .onFailure { logger.warning("Fact-catalog publish failed: ${it.message}") } + } + + companion object { + private fun scanPersistableFacts(): Map = + Query.find().associate { it.id to it.name } + } +} diff --git a/src/test/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisherTest.kt b/src/test/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisherTest.kt new file mode 100644 index 0000000..9b129cb --- /dev/null +++ b/src/test/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisherTest.kt @@ -0,0 +1,51 @@ +package fr.perrier.inkwell.storage + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.logging.Logger + +/** Unit tests for [FactCatalogPublisher] with the Mongo catalog mocked (no Docker needed). */ +class FactCatalogPublisherTest { + + private val logger = Logger.getLogger("FactCatalogPublisherTest") + + @Test + fun `publish sends the scanned entries to the catalog`() { + val catalog = mockk() + val captured = slot>() + coEvery { catalog.publish(capture(captured)) } returns Unit + + val publisher = FactCatalogPublisher(catalog, logger) { mapOf("id1" to "name1") } + publisher.publish() + + coVerify(exactly = 1) { catalog.publish(any()) } + assertEquals("name1", captured.captured["id1"]) + } + + @Test + fun `publish skips when nothing changed since last time`() { + val catalog = mockk() + coEvery { catalog.publish(any()) } returns Unit + + val publisher = FactCatalogPublisher(catalog, logger) { mapOf("id1" to "name1") } + publisher.publish() + publisher.publish() + + coVerify(exactly = 1) { catalog.publish(any()) } + } + + @Test + fun `publish skips an empty scan (content not loaded yet)`() { + val catalog = mockk() + coEvery { catalog.publish(any()) } returns Unit + + val publisher = FactCatalogPublisher(catalog, logger) { emptyMap() } + publisher.publish() + + coVerify(exactly = 0) { catalog.publish(any()) } + } +} From 82fc47ff2c492b4c8b35f49765a615d07a586cfa Mon Sep 17 00:00:00 2001 From: chikage Date: Sat, 27 Jun 2026 23:20:00 +0200 Subject: [PATCH 09/12] refactor: extract FactCacheAccess (shared reflection + keyed read) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DSbnaoxxpzqfrNexWsaVj5 --- .../inkwell/storage/FactCacheAccess.kt | 31 ++++++++++++++ .../inkwell/storage/FactSessionSync.kt | 16 +------ .../inkwell/storage/FactCacheAccessTest.kt | 42 +++++++++++++++++++ 3 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 src/main/kotlin/fr/perrier/inkwell/storage/FactCacheAccess.kt create mode 100644 src/test/kotlin/fr/perrier/inkwell/storage/FactCacheAccessTest.kt diff --git a/src/main/kotlin/fr/perrier/inkwell/storage/FactCacheAccess.kt b/src/main/kotlin/fr/perrier/inkwell/storage/FactCacheAccess.kt new file mode 100644 index 0000000..00f9fab --- /dev/null +++ b/src/main/kotlin/fr/perrier/inkwell/storage/FactCacheAccess.kt @@ -0,0 +1,31 @@ +package fr.perrier.inkwell.storage + +import com.typewritermc.engine.paper.entry.entries.GroupId +import com.typewritermc.engine.paper.facts.FactData +import com.typewritermc.engine.paper.facts.FactDatabase +import com.typewritermc.engine.paper.facts.FactId +import org.koin.core.context.GlobalContext +import java.util.UUID +import java.util.logging.Logger + +/** + * Shared access to Typewriter's private [FactDatabase] fact cache. Typewriter exposes no per-player hook, + * so we reach the cache by guarded reflection — used both by [FactSessionSync] (cross-server sync) and by + * the `%inkwell_fact_%` placeholder (read-only display). + */ +object FactCacheAccess { + + @Suppress("UNCHECKED_CAST") + fun reflect(logger: Logger): MutableMap? = + runCatching { + val factDatabase = GlobalContext.get().get() + val field = FactDatabase::class.java.getDeclaredField("cache").apply { isAccessible = true } + field.get(factDatabase) as MutableMap + }.onFailure { + logger.severe("Could not access FactDatabase cache via reflection — cross-server facts disabled: ${it.message}") + }.getOrNull() + + /** Read one player's value for [entryId] from the loaded cache, or null if absent. O(1) keyed lookup. */ + fun read(cache: Map, entryId: String, uuid: UUID): Int? = + cache[FactId(entryId, GroupId(uuid))]?.value +} diff --git a/src/main/kotlin/fr/perrier/inkwell/storage/FactSessionSync.kt b/src/main/kotlin/fr/perrier/inkwell/storage/FactSessionSync.kt index 20a4400..8a4be72 100644 --- a/src/main/kotlin/fr/perrier/inkwell/storage/FactSessionSync.kt +++ b/src/main/kotlin/fr/perrier/inkwell/storage/FactSessionSync.kt @@ -4,10 +4,8 @@ import com.typewritermc.core.entries.Query import com.typewritermc.engine.paper.entry.entries.ExpirableFactEntry import com.typewritermc.engine.paper.entry.entries.PersistableFactEntry import com.typewritermc.engine.paper.facts.FactData -import com.typewritermc.engine.paper.facts.FactDatabase import com.typewritermc.engine.paper.facts.FactId import kotlinx.coroutines.runBlocking -import org.koin.core.context.GlobalContext import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.logging.Logger @@ -30,7 +28,7 @@ import java.util.logging.Logger class FactSessionSync( private val storage: MongoFactStorage, private val logger: Logger, - cacheProvider: () -> MutableMap? = { reflectFactCache(logger) }, + cacheProvider: () -> MutableMap? = { FactCacheAccess.reflect(logger) }, ) { private val cache: MutableMap? by lazy(cacheProvider) @@ -120,16 +118,4 @@ class FactSessionSync( if (entry is ExpirableFactEntry && entry.hasExpired(id, data)) return false return true } - - companion object { - @Suppress("UNCHECKED_CAST") - private fun reflectFactCache(logger: Logger): MutableMap? = - runCatching { - val factDatabase = GlobalContext.get().get() - val field = FactDatabase::class.java.getDeclaredField("cache").apply { isAccessible = true } - field.get(factDatabase) as MutableMap - }.onFailure { - logger.severe("Could not access FactDatabase cache via reflection — cross-server facts disabled: ${it.message}") - }.getOrNull() - } } diff --git a/src/test/kotlin/fr/perrier/inkwell/storage/FactCacheAccessTest.kt b/src/test/kotlin/fr/perrier/inkwell/storage/FactCacheAccessTest.kt new file mode 100644 index 0000000..53fd81c --- /dev/null +++ b/src/test/kotlin/fr/perrier/inkwell/storage/FactCacheAccessTest.kt @@ -0,0 +1,42 @@ +package fr.perrier.inkwell.storage + +import com.typewritermc.engine.paper.entry.entries.GroupId +import com.typewritermc.engine.paper.facts.FactData +import com.typewritermc.engine.paper.facts.FactId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.UUID + +/** Pure tests for the keyed cache read — no Typewriter runtime / reflection involved. */ +class FactCacheAccessTest { + + private val now: LocalDateTime = LocalDateTime.parse("2026-05-23T10:30:00") + + @Test + fun `read returns the value for a matching entry id and player`() { + val uuid = UUID.randomUUID() + val cache = hashMapOf( + FactId("entry1", GroupId(uuid)) to FactData(5, now), + ) + assertEquals(5, FactCacheAccess.read(cache, "entry1", uuid)) + } + + @Test + fun `read returns null when the fact is absent`() { + val uuid = UUID.randomUUID() + val cache = hashMapOf() + assertNull(FactCacheAccess.read(cache, "missing", uuid)) + } + + @Test + fun `read returns null for a different player`() { + val mine = UUID.randomUUID() + val other = UUID.randomUUID() + val cache = hashMapOf( + FactId("entry1", GroupId(other)) to FactData(9, now), + ) + assertNull(FactCacheAccess.read(cache, "entry1", mine)) + } +} From 45efbab16e20b7a56d1545cf26c02f0083816233 Mon Sep 17 00:00:00 2001 From: chikage Date: Sat, 27 Jun 2026 23:20:00 +0200 Subject: [PATCH 10/12] feat: add fact placeholder resolver + PlaceholderAPI expansion Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DSbnaoxxpzqfrNexWsaVj5 --- .../inkwell/FactPlaceholderResolver.kt | 31 +++++++++ .../inkwell/InkwellPlaceholderExpansion.kt | 23 +++++++ .../inkwell/FactPlaceholderResolverTest.kt | 64 +++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 src/main/kotlin/fr/perrier/inkwell/FactPlaceholderResolver.kt create mode 100644 src/main/kotlin/fr/perrier/inkwell/InkwellPlaceholderExpansion.kt create mode 100644 src/test/kotlin/fr/perrier/inkwell/FactPlaceholderResolverTest.kt diff --git a/src/main/kotlin/fr/perrier/inkwell/FactPlaceholderResolver.kt b/src/main/kotlin/fr/perrier/inkwell/FactPlaceholderResolver.kt new file mode 100644 index 0000000..8cdb7a4 --- /dev/null +++ b/src/main/kotlin/fr/perrier/inkwell/FactPlaceholderResolver.kt @@ -0,0 +1,31 @@ +package fr.perrier.inkwell + +import com.typewritermc.engine.paper.facts.FactData +import com.typewritermc.engine.paper.facts.FactId +import fr.perrier.inkwell.storage.FactCacheAccess +import java.util.UUID + +/** + * Pure resolution logic behind `%inkwell_fact_%`: name -> entryId (via the synced catalog) -> + * value (from Typewriter's already-loaded fact cache). Returns [default] for an unknown name, an unset + * value, or no player; returns `null` when [params] isn't a `fact_` placeholder, so PlaceholderAPI keeps + * looking. No Mongo I/O here — both inputs are provided by the caller. + */ +class FactPlaceholderResolver( + private val catalogProvider: () -> Map, + private val cacheProvider: () -> Map?, + private val default: String, +) { + fun resolve(params: String, uuid: UUID?): String? { + if (!params.startsWith(PREFIX)) return null + if (uuid == null) return default + val name = params.removePrefix(PREFIX) + val entryId = catalogProvider()[name] ?: return default + val cache = cacheProvider() ?: return default + return (FactCacheAccess.read(cache, entryId, uuid) ?: return default).toString() + } + + companion object { + const val PREFIX = "fact_" + } +} diff --git a/src/main/kotlin/fr/perrier/inkwell/InkwellPlaceholderExpansion.kt b/src/main/kotlin/fr/perrier/inkwell/InkwellPlaceholderExpansion.kt new file mode 100644 index 0000000..5d9cf67 --- /dev/null +++ b/src/main/kotlin/fr/perrier/inkwell/InkwellPlaceholderExpansion.kt @@ -0,0 +1,23 @@ +package fr.perrier.inkwell + +import me.clip.placeholderapi.expansion.PlaceholderExpansion +import org.bukkit.OfflinePlayer + +/** + * Thin PlaceholderAPI adapter exposing `%inkwell_fact_%`. All logic lives in + * [FactPlaceholderResolver]; this class only bridges PlaceholderAPI's contract. `persist() = true` keeps + * it registered across `/papi reload` (it holds live references to Inkwell's in-memory state). + */ +class InkwellPlaceholderExpansion( + private val resolver: FactPlaceholderResolver, + private val version: String, +) : PlaceholderExpansion() { + + override fun getIdentifier(): String = "inkwell" + override fun getAuthor(): String = "PerrierBottle" + override fun getVersion(): String = version + override fun persist(): Boolean = true + + override fun onRequest(player: OfflinePlayer?, params: String): String? = + resolver.resolve(params, player?.uniqueId) +} diff --git a/src/test/kotlin/fr/perrier/inkwell/FactPlaceholderResolverTest.kt b/src/test/kotlin/fr/perrier/inkwell/FactPlaceholderResolverTest.kt new file mode 100644 index 0000000..e53cfa4 --- /dev/null +++ b/src/test/kotlin/fr/perrier/inkwell/FactPlaceholderResolverTest.kt @@ -0,0 +1,64 @@ +package fr.perrier.inkwell + +import com.typewritermc.engine.paper.entry.entries.GroupId +import com.typewritermc.engine.paper.facts.FactData +import com.typewritermc.engine.paper.facts.FactId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.UUID + +class FactPlaceholderResolverTest { + + private val now: LocalDateTime = LocalDateTime.parse("2026-05-23T10:30:00") + private val uuid: UUID = UUID.randomUUID() + + private fun resolver( + catalog: Map, + cache: Map?, + ) = FactPlaceholderResolver( + catalogProvider = { catalog }, + cacheProvider = { cache }, + default = "0", + ) + + @Test + fun `resolves a known fact to its value`() { + val r = resolver( + catalog = mapOf("p1_kill" to "entry1"), + cache = mapOf(FactId("entry1", GroupId(uuid)) to FactData(3, now)), + ) + assertEquals("3", r.resolve("fact_p1_kill", uuid)) + } + + @Test + fun `unknown name returns the default`() { + val r = resolver(catalog = emptyMap(), cache = emptyMap()) + assertEquals("0", r.resolve("fact_unknown", uuid)) + } + + @Test + fun `name in catalog but value unset returns the default`() { + val r = resolver(catalog = mapOf("p1_kill" to "entry1"), cache = emptyMap()) + assertEquals("0", r.resolve("fact_p1_kill", uuid)) + } + + @Test + fun `null player returns the default`() { + val r = resolver(catalog = mapOf("p1_kill" to "entry1"), cache = emptyMap()) + assertEquals("0", r.resolve("fact_p1_kill", null)) + } + + @Test + fun `params not starting with fact_ returns null`() { + val r = resolver(catalog = emptyMap(), cache = emptyMap()) + assertNull(r.resolve("something_else", uuid)) + } + + @Test + fun `null cache returns the default`() { + val r = resolver(catalog = mapOf("p1_kill" to "entry1"), cache = null) + assertEquals("0", r.resolve("fact_p1_kill", uuid)) + } +} From 70c5a1b290863f21d40aae3f395a34586c374ac0 Mon Sep 17 00:00:00 2001 From: chikage Date: Sat, 27 Jun 2026 23:20:00 +0200 Subject: [PATCH 11/12] feat: wire fact catalog publisher + %inkwell_fact% placeholder into the plugin Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DSbnaoxxpzqfrNexWsaVj5 --- README.md | 21 +++++++ .../fr/perrier/inkwell/InkwellPlugin.kt | 58 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fdb8aa9..03f02a2 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ the player to the next. - **Pages** → MongoDB. The published Typewriter content is restored to disk before Typewriter reads it and pushed back whenever you publish. - **Snippets** → `snippets.yml` synced to MongoDB. +- **Fact placeholders** → read any persisted fact on *any* server (even one without the defining page) + via PlaceholderAPI: `%inkwell_fact_%`. A small `fact_catalog` collection syncs each fact's + name→id; the value comes from the player's already-loaded fact cache (no per-render database hit). - **Per-feature toggles** — enable only what you need; anything disabled stays on local disk (Typewriter's default behaviour). @@ -87,6 +90,24 @@ them from MongoDB **before** Typewriter loads, and pushes them back when you pub (`StagingChangeEvent`) and on shutdown. MongoDB is the source of truth; on first run an existing local copy is seeded into the database. +### Fact placeholders (cross-server display) + +`/tw facts query ` only works where the fact's **entry** is defined (i.e. its page is present), so a +hub that doesn't run a floor's quest content answers `Could not find entry` even though the value followed +the player. To display such facts without copying pages between servers, enable `storage.fact_catalog` and +use PlaceholderAPI: + +``` +%inkwell_fact_% e.g. %inkwell_fact_p1_p01_kill_objective_statut% +``` + +Servers that define the entries publish a `name → id` catalog to MongoDB (upsert-only — a server never +prunes entries it doesn't have). Any server resolves the name to an id from that catalog and reads the +value straight from the connecting player's loaded fact cache. The consuming server must also run with +`storage.facts: true`. Returns `fact_placeholder_default` (default `"0"`) when the fact is unknown +network-wide or unset for the player. The placeholder serves the **online player being rendered**; offline +players / other-player leaderboards are out of scope. + ## Data model **Facts** — one document per `(entryId, groupId)`: diff --git a/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt b/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt index 8da3b98..ed10c8a 100644 --- a/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt +++ b/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt @@ -1,9 +1,14 @@ package fr.perrier.inkwell import com.mongodb.kotlin.client.coroutine.MongoDatabase +import com.typewritermc.engine.paper.facts.FactData +import com.typewritermc.engine.paper.facts.FactId import com.typewritermc.engine.paper.facts.FactStorage import fr.perrier.inkwell.config.DatabaseConfig +import fr.perrier.inkwell.storage.FactCacheAccess +import fr.perrier.inkwell.storage.FactCatalogPublisher import fr.perrier.inkwell.storage.FactSessionSync +import fr.perrier.inkwell.storage.MongoFactCatalog import fr.perrier.inkwell.storage.MongoFactStorage import fr.perrier.inkwell.storage.MongoFileSync import fr.perrier.inkwell.storage.MongoPageSync @@ -20,9 +25,16 @@ class InkwellPlugin : JavaPlugin() { private var pageSync: MongoPageSync? = null private var fileSync: MongoFileSync? = null private var factSync: FactSessionSync? = null + private var catalogPublisher: FactCatalogPublisher? = null private lateinit var databaseConfig: DatabaseConfig private lateinit var snippetsFile: File + @Volatile + private var catalogMap: Map = emptyMap() + + // Reflected once on first placeholder use; the FactDatabase.cache reference is stable thereafter. + private val factCache: MutableMap? by lazy { FactCacheAccess.reflect(logger) } + override fun onLoad() { saveDefaultConfig() databaseConfig = DatabaseConfig.from(config) @@ -59,9 +71,16 @@ class InkwellPlugin : JavaPlugin() { } override fun onEnable() { - // Push pages to Mongo whenever Typewriter publishes its staging state. - if (databaseConfig.persistPages || databaseConfig.persistSnippets) { - server.pluginManager.registerEvents(StagingPublishListener(::backupAll), this) + // On a Typewriter publish: back up pages/snippets AND republish the fact catalog (content + // changes may add/rename fact entries). Each leg is independently gated and no-ops if disabled. + if (databaseConfig.persistPages || databaseConfig.persistSnippets || databaseConfig.persistFactCatalog) { + server.pluginManager.registerEvents( + StagingPublishListener { + backupAll() + catalogPublisher?.publish() + }, + this, + ) } // Per-player fact sync: load a player's facts on connect, save them on quit (network-shared). @@ -76,6 +95,9 @@ class InkwellPlugin : JavaPlugin() { logger.warning("FactStorage is not MongoFactStorage — per-player fact sync disabled") } } + + if (databaseConfig.persistFactCatalog) startFactCatalog() + logger.info("Inkwell enabled") } @@ -94,6 +116,36 @@ class InkwellPlugin : JavaPlugin() { }, ticks, ticks) } + /** + * Brings up the cross-server fact catalog: publishes this server's persistable fact entries (id->name) + * to Mongo on a timer + on publish, keeps a local name->id map refreshed, and registers the + * %inkwell_fact_% placeholder if PlaceholderAPI is installed. The placeholder reads values from + * Typewriter's already-loaded fact cache, so it needs no Mongo I/O per render. + */ + private fun startFactCatalog() { + val db = GlobalContext.get().get() + val catalog = MongoFactCatalog(db, databaseConfig, logger) + val publisher = FactCatalogPublisher(catalog, logger) + catalogPublisher = publisher + + val refreshTicks = (databaseConfig.catalogRefreshSeconds * 20L).coerceAtLeast(1L) + // Publish our entries (delayed start lets Typewriter finish loading content; an early empty scan + // simply no-ops and the next tick succeeds), then on the refresh cadence. + server.scheduler.runTaskTimerAsynchronously(this, Runnable { publisher.publish() }, 100L, refreshTicks) + // Keep the consumer-side name->id map fresh; on Mongo failure loadAll() returns empty, so guard it. + server.scheduler.runTaskTimerAsynchronously(this, Runnable { + runBlocking { catalog.loadAll() }.takeIf { it.isNotEmpty() }?.let { catalogMap = it } + }, 20L, refreshTicks) + + if (server.pluginManager.getPlugin("PlaceholderAPI") != null) { + val resolver = FactPlaceholderResolver({ catalogMap }, { factCache }, databaseConfig.factPlaceholderDefault) + InkwellPlaceholderExpansion(resolver, description.version).register() + logger.info("Registered PlaceholderAPI expansion 'inkwell' (%inkwell_fact_%)") + } else { + logger.info("PlaceholderAPI not found — %inkwell_fact_% placeholder disabled") + } + } + override fun onDisable() { // Reconcile still-online players so deletions (e.g. /tw facts reset) reach the DB even when // their quit doesn't fire before disable — Typewriter's shutdown flush is upsert-only. This From 1a4d35bf4cff2b34a811af3c25de96fcaa57711b Mon Sep 17 00:00:00 2001 From: chikage Date: Sun, 28 Jun 2026 00:19:28 +0200 Subject: [PATCH 12/12] refactor: resolve %inkwell_fact% by entry id, drop the name catalog The placeholder now takes the entry id directly and reads the value from the already-loaded fact cache, so the name->id catalog is unnecessary. Removes MongoFactCatalog/FactCatalogPublisher and their tests/config; the fact_catalog toggle becomes fact_placeholder. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DSbnaoxxpzqfrNexWsaVj5 --- README.md | 26 +++---- ...27-cross-server-fact-placeholder-design.md | 8 +- .../inkwell/FactPlaceholderResolver.kt | 12 ++- .../fr/perrier/inkwell/InkwellPlugin.kt | 56 ++++---------- .../perrier/inkwell/config/DatabaseConfig.kt | 12 +-- .../inkwell/storage/FactCatalogPublisher.kt | 40 ---------- .../inkwell/storage/MongoFactCatalog.kt | 62 --------------- src/main/resources/config.yml | 13 +--- .../inkwell/FactPlaceholderResolverTest.kt | 41 ++++------ .../storage/FactCatalogPublisherTest.kt | 51 ------------- .../inkwell/storage/MongoFactCatalogTest.kt | 76 ------------------- 11 files changed, 60 insertions(+), 337 deletions(-) delete mode 100644 src/main/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisher.kt delete mode 100644 src/main/kotlin/fr/perrier/inkwell/storage/MongoFactCatalog.kt delete mode 100644 src/test/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisherTest.kt delete mode 100644 src/test/kotlin/fr/perrier/inkwell/storage/MongoFactCatalogTest.kt diff --git a/README.md b/README.md index 03f02a2..cc681ac 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ the player to the next. - **Pages** → MongoDB. The published Typewriter content is restored to disk before Typewriter reads it and pushed back whenever you publish. - **Snippets** → `snippets.yml` synced to MongoDB. -- **Fact placeholders** → read any persisted fact on *any* server (even one without the defining page) - via PlaceholderAPI: `%inkwell_fact_%`. A small `fact_catalog` collection syncs each fact's - name→id; the value comes from the player's already-loaded fact cache (no per-render database hit). +- **Fact placeholder** → read any persisted fact on *any* server (even one without the defining page) + via PlaceholderAPI: `%inkwell_fact_%`. The value comes from the player's already-loaded fact + cache (no database hit), keyed by the entry's id. - **Per-feature toggles** — enable only what you need; anything disabled stays on local disk (Typewriter's default behaviour). @@ -90,23 +90,23 @@ them from MongoDB **before** Typewriter loads, and pushes them back when you pub (`StagingChangeEvent`) and on shutdown. MongoDB is the source of truth; on first run an existing local copy is seeded into the database. -### Fact placeholders (cross-server display) +### Fact placeholder (cross-server display) `/tw facts query ` only works where the fact's **entry** is defined (i.e. its page is present), so a hub that doesn't run a floor's quest content answers `Could not find entry` even though the value followed -the player. To display such facts without copying pages between servers, enable `storage.fact_catalog` and -use PlaceholderAPI: +the player. To display such facts without copying pages between servers, enable `storage.fact_placeholder` +and use PlaceholderAPI with the entry's **id**: ``` -%inkwell_fact_% e.g. %inkwell_fact_p1_p01_kill_objective_statut% +%inkwell_fact_% ``` -Servers that define the entries publish a `name → id` catalog to MongoDB (upsert-only — a server never -prunes entries it doesn't have). Any server resolves the name to an id from that catalog and reads the -value straight from the connecting player's loaded fact cache. The consuming server must also run with -`storage.facts: true`. Returns `fact_placeholder_default` (default `"0"`) when the fact is unknown -network-wide or unset for the player. The placeholder serves the **online player being rendered**; offline -players / other-player leaderboards are out of scope. +The value is read straight from the connecting player's loaded fact cache (Inkwell loads every player's +facts on join regardless of whether the entry is defined locally), so there is no database hit per render. +The consuming server must run with `storage.facts: true`. Returns `fact_placeholder_default` (default +`"0"`) when the fact is unset for the player. Find an entry's id in its Typewriter page JSON (the entry's +`"id"` field) or in MongoDB: `db.facts.find({ groupId: "" })`. The placeholder serves the +**online player being rendered**. ## Data model diff --git a/docs/superpowers/specs/2026-06-27-cross-server-fact-placeholder-design.md b/docs/superpowers/specs/2026-06-27-cross-server-fact-placeholder-design.md index fa48f17..25601b4 100644 --- a/docs/superpowers/specs/2026-06-27-cross-server-fact-placeholder-design.md +++ b/docs/superpowers/specs/2026-06-27-cross-server-fact-placeholder-design.md @@ -1,7 +1,13 @@ # Cross-server fact placeholder — design **Date:** 2026-06-27 -**Status:** Approved (design), pending implementation plan +**Status:** Implemented, then simplified — see update note below. + +> **Update (2026-06-28):** the name→id catalog described below was dropped at the user's request. The +> placeholder now takes the entry **id** directly (`%inkwell_fact_%`) and reads the value from the +> already-loaded fact cache, so `MongoFactCatalog`, `FactCatalogPublisher`, the consumer-side catalog +> refresh, and the `catalog_collection` / `catalog_refresh_seconds` config were removed. The `fact_catalog` +> toggle became `fact_placeholder`. Everything below about the catalog is historical context. ## Problem diff --git a/src/main/kotlin/fr/perrier/inkwell/FactPlaceholderResolver.kt b/src/main/kotlin/fr/perrier/inkwell/FactPlaceholderResolver.kt index 8cdb7a4..ebc02a0 100644 --- a/src/main/kotlin/fr/perrier/inkwell/FactPlaceholderResolver.kt +++ b/src/main/kotlin/fr/perrier/inkwell/FactPlaceholderResolver.kt @@ -6,21 +6,19 @@ import fr.perrier.inkwell.storage.FactCacheAccess import java.util.UUID /** - * Pure resolution logic behind `%inkwell_fact_%`: name -> entryId (via the synced catalog) -> - * value (from Typewriter's already-loaded fact cache). Returns [default] for an unknown name, an unset - * value, or no player; returns `null` when [params] isn't a `fact_` placeholder, so PlaceholderAPI keeps - * looking. No Mongo I/O here — both inputs are provided by the caller. + * Pure resolution logic behind `%inkwell_fact_%`: reads the value for `entryId` straight from + * Typewriter's already-loaded fact cache. Returns [default] for an unset value or no player; returns + * `null` when [params] isn't a `fact_` placeholder, so PlaceholderAPI keeps looking. No database I/O — + * the cache is provided by the caller. */ class FactPlaceholderResolver( - private val catalogProvider: () -> Map, private val cacheProvider: () -> Map?, private val default: String, ) { fun resolve(params: String, uuid: UUID?): String? { if (!params.startsWith(PREFIX)) return null if (uuid == null) return default - val name = params.removePrefix(PREFIX) - val entryId = catalogProvider()[name] ?: return default + val entryId = params.removePrefix(PREFIX) val cache = cacheProvider() ?: return default return (FactCacheAccess.read(cache, entryId, uuid) ?: return default).toString() } diff --git a/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt b/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt index ed10c8a..0d07513 100644 --- a/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt +++ b/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt @@ -6,9 +6,7 @@ import com.typewritermc.engine.paper.facts.FactId import com.typewritermc.engine.paper.facts.FactStorage import fr.perrier.inkwell.config.DatabaseConfig import fr.perrier.inkwell.storage.FactCacheAccess -import fr.perrier.inkwell.storage.FactCatalogPublisher import fr.perrier.inkwell.storage.FactSessionSync -import fr.perrier.inkwell.storage.MongoFactCatalog import fr.perrier.inkwell.storage.MongoFactStorage import fr.perrier.inkwell.storage.MongoFileSync import fr.perrier.inkwell.storage.MongoPageSync @@ -25,13 +23,9 @@ class InkwellPlugin : JavaPlugin() { private var pageSync: MongoPageSync? = null private var fileSync: MongoFileSync? = null private var factSync: FactSessionSync? = null - private var catalogPublisher: FactCatalogPublisher? = null private lateinit var databaseConfig: DatabaseConfig private lateinit var snippetsFile: File - @Volatile - private var catalogMap: Map = emptyMap() - // Reflected once on first placeholder use; the FactDatabase.cache reference is stable thereafter. private val factCache: MutableMap? by lazy { FactCacheAccess.reflect(logger) } @@ -71,16 +65,9 @@ class InkwellPlugin : JavaPlugin() { } override fun onEnable() { - // On a Typewriter publish: back up pages/snippets AND republish the fact catalog (content - // changes may add/rename fact entries). Each leg is independently gated and no-ops if disabled. - if (databaseConfig.persistPages || databaseConfig.persistSnippets || databaseConfig.persistFactCatalog) { - server.pluginManager.registerEvents( - StagingPublishListener { - backupAll() - catalogPublisher?.publish() - }, - this, - ) + // Push pages to Mongo whenever Typewriter publishes its staging state. + if (databaseConfig.persistPages || databaseConfig.persistSnippets) { + server.pluginManager.registerEvents(StagingPublishListener(::backupAll), this) } // Per-player fact sync: load a player's facts on connect, save them on quit (network-shared). @@ -96,7 +83,7 @@ class InkwellPlugin : JavaPlugin() { } } - if (databaseConfig.persistFactCatalog) startFactCatalog() + if (databaseConfig.factPlaceholderEnabled) startFactPlaceholder() logger.info("Inkwell enabled") } @@ -117,33 +104,18 @@ class InkwellPlugin : JavaPlugin() { } /** - * Brings up the cross-server fact catalog: publishes this server's persistable fact entries (id->name) - * to Mongo on a timer + on publish, keeps a local name->id map refreshed, and registers the - * %inkwell_fact_% placeholder if PlaceholderAPI is installed. The placeholder reads values from - * Typewriter's already-loaded fact cache, so it needs no Mongo I/O per render. + * Registers the %inkwell_fact_% placeholder if PlaceholderAPI is installed. It reads a fact's + * value by entry id straight from Typewriter's already-loaded fact cache (Inkwell loads every player's + * facts on join, even for entries this server doesn't define), so it needs no database I/O per render. */ - private fun startFactCatalog() { - val db = GlobalContext.get().get() - val catalog = MongoFactCatalog(db, databaseConfig, logger) - val publisher = FactCatalogPublisher(catalog, logger) - catalogPublisher = publisher - - val refreshTicks = (databaseConfig.catalogRefreshSeconds * 20L).coerceAtLeast(1L) - // Publish our entries (delayed start lets Typewriter finish loading content; an early empty scan - // simply no-ops and the next tick succeeds), then on the refresh cadence. - server.scheduler.runTaskTimerAsynchronously(this, Runnable { publisher.publish() }, 100L, refreshTicks) - // Keep the consumer-side name->id map fresh; on Mongo failure loadAll() returns empty, so guard it. - server.scheduler.runTaskTimerAsynchronously(this, Runnable { - runBlocking { catalog.loadAll() }.takeIf { it.isNotEmpty() }?.let { catalogMap = it } - }, 20L, refreshTicks) - - if (server.pluginManager.getPlugin("PlaceholderAPI") != null) { - val resolver = FactPlaceholderResolver({ catalogMap }, { factCache }, databaseConfig.factPlaceholderDefault) - InkwellPlaceholderExpansion(resolver, description.version).register() - logger.info("Registered PlaceholderAPI expansion 'inkwell' (%inkwell_fact_%)") - } else { - logger.info("PlaceholderAPI not found — %inkwell_fact_% placeholder disabled") + private fun startFactPlaceholder() { + if (server.pluginManager.getPlugin("PlaceholderAPI") == null) { + logger.info("PlaceholderAPI not found — %inkwell_fact_% placeholder disabled") + return } + val resolver = FactPlaceholderResolver({ factCache }, databaseConfig.factPlaceholderDefault) + InkwellPlaceholderExpansion(resolver, description.version).register() + logger.info("Registered PlaceholderAPI expansion 'inkwell' (%inkwell_fact_%)") } override fun onDisable() { diff --git a/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt b/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt index 4f8e528..0b0da7f 100644 --- a/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt +++ b/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt @@ -20,11 +20,9 @@ data class DatabaseConfig( val username: String? = null, val password: String? = null, val authSource: String? = null, - // Cross-server fact display. The catalog maps each fact entry's generated id to its name so a - // server without the defining page can resolve a fact by name. Upsert-only, network-shared. - val catalogCollection: String = "fact_catalog", - val persistFactCatalog: Boolean = true, - val catalogRefreshSeconds: Long = 300, + // Cross-server fact display: register the %inkwell_fact_% PlaceholderAPI placeholder, which + // reads a fact's value by entry id from the player's loaded cache (works on servers without the page). + val factPlaceholderEnabled: Boolean = true, val factPlaceholderDefault: String = "0", ) { companion object { @@ -40,9 +38,7 @@ data class DatabaseConfig( username = section.getString("username")?.takeIf { it.isNotBlank() }, password = section.getString("password")?.takeIf { it.isNotBlank() }, authSource = section.getString("auth_source")?.takeIf { it.isNotBlank() }, - catalogCollection = section.getString("catalog_collection") ?: "fact_catalog", - persistFactCatalog = storage?.getBoolean("fact_catalog", true) ?: true, - catalogRefreshSeconds = storage?.getLong("catalog_refresh_seconds", 300L) ?: 300L, + factPlaceholderEnabled = storage?.getBoolean("fact_placeholder", true) ?: true, factPlaceholderDefault = storage?.getString("fact_placeholder_default") ?: "0", factsCollection = section.getString("facts_collection") ?: "facts", pagesCollection = section.getString("pages_collection") ?: "pages", diff --git a/src/main/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisher.kt b/src/main/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisher.kt deleted file mode 100644 index af06e32..0000000 --- a/src/main/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisher.kt +++ /dev/null @@ -1,40 +0,0 @@ -package fr.perrier.inkwell.storage - -import com.typewritermc.core.entries.Query -import com.typewritermc.engine.paper.entry.entries.PersistableFactEntry -import kotlinx.coroutines.runBlocking -import java.util.logging.Logger - -/** - * Publishes this server's persistable fact entries (`id -> name`) to the shared [MongoFactCatalog] so - * other servers can resolve those facts by name for display. Only **persistable** facts are published — - * they are the only ones whose values travel via Inkwell and land in another server's fact cache. - * - * [entriesProvider] is injectable for testing; by default it scans Typewriter's loaded content. Publishes - * only when the scanned set is non-empty (skips the startup race where content isn't loaded yet) and has - * changed since the last publish (idle cycles are free). - */ -class FactCatalogPublisher( - private val catalog: MongoFactCatalog, - private val logger: Logger, - private val entriesProvider: () -> Map = ::scanPersistableFacts, -) { - @Volatile - private var lastPublished: Map = emptyMap() - - fun publish() { - val current = runCatching(entriesProvider).getOrElse { - logger.warning("Fact-catalog scan failed: ${it.message}") - return - } - if (current.isEmpty() || current == lastPublished) return - lastPublished = current - runCatching { runBlocking { catalog.publish(current) } } - .onFailure { logger.warning("Fact-catalog publish failed: ${it.message}") } - } - - companion object { - private fun scanPersistableFacts(): Map = - Query.find().associate { it.id to it.name } - } -} diff --git a/src/main/kotlin/fr/perrier/inkwell/storage/MongoFactCatalog.kt b/src/main/kotlin/fr/perrier/inkwell/storage/MongoFactCatalog.kt deleted file mode 100644 index 77c7312..0000000 --- a/src/main/kotlin/fr/perrier/inkwell/storage/MongoFactCatalog.kt +++ /dev/null @@ -1,62 +0,0 @@ -package fr.perrier.inkwell.storage - -import com.mongodb.client.model.Filters -import com.mongodb.client.model.ReplaceOneModel -import com.mongodb.client.model.ReplaceOptions -import com.mongodb.kotlin.client.coroutine.MongoCollection -import com.mongodb.kotlin.client.coroutine.MongoDatabase -import fr.perrier.inkwell.config.DatabaseConfig -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.withContext -import org.bson.codecs.pojo.annotations.BsonId -import java.util.logging.Logger - -/** - * A network-shared catalog mapping each fact entry's generated id to its human name, so a server that - * doesn't define an entry (e.g. a hub) can still resolve a fact by name for display. One document per - * entry: `{ _id: , name: }`. - * - * [publish] is UPSERT-ONLY and never deletes: a server that lacks an entry must not prune it from the - * shared catalog — exactly like [MongoFactStorage.storeFacts] never wipes other servers' facts. - */ -class MongoFactCatalog( - private val database: MongoDatabase, - private val config: DatabaseConfig, - private val logger: Logger, -) { - private val collection: MongoCollection by lazy { - database.getCollection(config.catalogCollection, CatalogDocument::class.java) - } - - suspend fun publish(idToName: Map) { - if (idToName.isEmpty()) return - withContext(IO) { - runCatching { - collection.bulkWrite( - idToName.map { (id, name) -> - ReplaceOneModel( - Filters.eq("_id", id), - CatalogDocument(id, name), - ReplaceOptions().upsert(true), - ) - }, - ) - }.onFailure { logger.warning("Mongo fact-catalog publish failed: ${it.message}") } - } - } - - suspend fun loadAll(): Map = withContext(IO) { - runCatching { - collection.find().toList().associate { it.name to it.id } - }.getOrElse { - logger.warning("Mongo fact-catalog loadAll failed: ${it.message}") - emptyMap() - } - } - - data class CatalogDocument( - @BsonId val id: String, - val name: String, - ) -} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index abd653f..1f2b6a3 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -13,9 +13,6 @@ database: pages_collection: "pages" # Collection holding raw file blobs synced to/from disk (e.g. snippets.yml). files_collection: "files" - # Collection mapping each fact entry's id to its name, so a server without a page can still resolve a - # fact by name for display (PlaceholderAPI). Upsert-only and shared across the network. - catalog_collection: "fact_catalog" # Name of the Typewriter plugin data folder, sibling of this plugin's folder. typewriter_folder: "Typewriter" timeout_ms: 5000 @@ -29,12 +26,10 @@ storage: pages: true # snippets.yml configuration. snippets: true - # Publish this server's fact-entry names to the catalog AND register the %inkwell_fact_% - # placeholder. The consuming server must also have facts: true (the value comes from the fact cache). - fact_catalog: true - # How often (seconds) each server republishes its catalog and reloads the name->id map. - catalog_refresh_seconds: 300 - # Value returned by %inkwell_fact_% when the fact is unknown on the network or unset for the player. + # Register the %inkwell_fact_% placeholder (PlaceholderAPI). Reads a fact's value by entry id + # from the player's loaded cache, so it works on servers without the defining page. Needs facts: true. + fact_placeholder: true + # Value returned by %inkwell_fact_% when the fact is unset for the player. fact_placeholder_default: "0" # How often (seconds) each online player's facts are reconciled to MongoDB. This is what makes # command-based changes (/tw facts set|add|reset) propagate across servers — gameplay changes are diff --git a/src/test/kotlin/fr/perrier/inkwell/FactPlaceholderResolverTest.kt b/src/test/kotlin/fr/perrier/inkwell/FactPlaceholderResolverTest.kt index e53cfa4..76516ad 100644 --- a/src/test/kotlin/fr/perrier/inkwell/FactPlaceholderResolverTest.kt +++ b/src/test/kotlin/fr/perrier/inkwell/FactPlaceholderResolverTest.kt @@ -14,51 +14,36 @@ class FactPlaceholderResolverTest { private val now: LocalDateTime = LocalDateTime.parse("2026-05-23T10:30:00") private val uuid: UUID = UUID.randomUUID() - private fun resolver( - catalog: Map, - cache: Map?, - ) = FactPlaceholderResolver( - catalogProvider = { catalog }, - cacheProvider = { cache }, - default = "0", - ) + private fun resolver(cache: Map?) = + FactPlaceholderResolver(cacheProvider = { cache }, default = "0") @Test - fun `resolves a known fact to its value`() { - val r = resolver( - catalog = mapOf("p1_kill" to "entry1"), - cache = mapOf(FactId("entry1", GroupId(uuid)) to FactData(3, now)), - ) - assertEquals("3", r.resolve("fact_p1_kill", uuid)) + fun `resolves a known entry id to its value`() { + val r = resolver(mapOf(FactId("entry1", GroupId(uuid)) to FactData(3, now))) + assertEquals("3", r.resolve("fact_entry1", uuid)) } @Test - fun `unknown name returns the default`() { - val r = resolver(catalog = emptyMap(), cache = emptyMap()) - assertEquals("0", r.resolve("fact_unknown", uuid)) - } - - @Test - fun `name in catalog but value unset returns the default`() { - val r = resolver(catalog = mapOf("p1_kill" to "entry1"), cache = emptyMap()) - assertEquals("0", r.resolve("fact_p1_kill", uuid)) + fun `unset value returns the default`() { + val r = resolver(emptyMap()) + assertEquals("0", r.resolve("fact_entry1", uuid)) } @Test fun `null player returns the default`() { - val r = resolver(catalog = mapOf("p1_kill" to "entry1"), cache = emptyMap()) - assertEquals("0", r.resolve("fact_p1_kill", null)) + val r = resolver(emptyMap()) + assertEquals("0", r.resolve("fact_entry1", null)) } @Test fun `params not starting with fact_ returns null`() { - val r = resolver(catalog = emptyMap(), cache = emptyMap()) + val r = resolver(emptyMap()) assertNull(r.resolve("something_else", uuid)) } @Test fun `null cache returns the default`() { - val r = resolver(catalog = mapOf("p1_kill" to "entry1"), cache = null) - assertEquals("0", r.resolve("fact_p1_kill", uuid)) + val r = resolver(null) + assertEquals("0", r.resolve("fact_entry1", uuid)) } } diff --git a/src/test/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisherTest.kt b/src/test/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisherTest.kt deleted file mode 100644 index 9b129cb..0000000 --- a/src/test/kotlin/fr/perrier/inkwell/storage/FactCatalogPublisherTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package fr.perrier.inkwell.storage - -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import io.mockk.slot -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import java.util.logging.Logger - -/** Unit tests for [FactCatalogPublisher] with the Mongo catalog mocked (no Docker needed). */ -class FactCatalogPublisherTest { - - private val logger = Logger.getLogger("FactCatalogPublisherTest") - - @Test - fun `publish sends the scanned entries to the catalog`() { - val catalog = mockk() - val captured = slot>() - coEvery { catalog.publish(capture(captured)) } returns Unit - - val publisher = FactCatalogPublisher(catalog, logger) { mapOf("id1" to "name1") } - publisher.publish() - - coVerify(exactly = 1) { catalog.publish(any()) } - assertEquals("name1", captured.captured["id1"]) - } - - @Test - fun `publish skips when nothing changed since last time`() { - val catalog = mockk() - coEvery { catalog.publish(any()) } returns Unit - - val publisher = FactCatalogPublisher(catalog, logger) { mapOf("id1" to "name1") } - publisher.publish() - publisher.publish() - - coVerify(exactly = 1) { catalog.publish(any()) } - } - - @Test - fun `publish skips an empty scan (content not loaded yet)`() { - val catalog = mockk() - coEvery { catalog.publish(any()) } returns Unit - - val publisher = FactCatalogPublisher(catalog, logger) { emptyMap() } - publisher.publish() - - coVerify(exactly = 0) { catalog.publish(any()) } - } -} diff --git a/src/test/kotlin/fr/perrier/inkwell/storage/MongoFactCatalogTest.kt b/src/test/kotlin/fr/perrier/inkwell/storage/MongoFactCatalogTest.kt deleted file mode 100644 index 52c86a8..0000000 --- a/src/test/kotlin/fr/perrier/inkwell/storage/MongoFactCatalogTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package fr.perrier.inkwell.storage - -import com.mongodb.kotlin.client.coroutine.MongoClient -import fr.perrier.inkwell.config.DatabaseConfig -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.testcontainers.containers.MongoDBContainer -import org.testcontainers.utility.DockerImageName -import java.util.logging.Logger - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class MongoFactCatalogTest { - - private val mongo = MongoDBContainer(DockerImageName.parse("mongo:7")) - private lateinit var client: MongoClient - private lateinit var catalog: MongoFactCatalog - - @BeforeAll - fun setUp() { - mongo.start() - val config = DatabaseConfig( - uri = mongo.replicaSetUrl, - databaseName = "test", - factsCollection = "facts", - pagesCollection = "pages", - filesCollection = "files", - typewriterFolder = "Typewriter", - timeoutMs = 5000, - persistFacts = true, - persistPages = true, - persistSnippets = true, - factSyncIntervalSeconds = 3, - catalogCollection = "fact_catalog", - ) - client = MongoClient.create(config.uri) - catalog = MongoFactCatalog( - client.getDatabase(config.databaseName), - config, - Logger.getLogger("MongoFactCatalogTest"), - ) - } - - @AfterAll - fun tearDown() { - client.close() - mongo.stop() - } - - @Test - fun `publish is upsert-only and unions across servers`() = runTest { - catalog.publish(mapOf("id_a" to "fact_a", "id_b" to "fact_b")) - // A second server that only knows id_b must NOT prune id_a from the shared catalog. - catalog.publish(mapOf("id_b" to "fact_b")) - - val all = catalog.loadAll() - assertEquals("id_a", all["fact_a"]) - assertEquals("id_b", all["fact_b"]) - } - - @Test - fun `loadAll maps name to id`() = runTest { - catalog.publish(mapOf("xyz123" to "quest_stage")) - assertEquals("xyz123", catalog.loadAll()["quest_stage"]) - } - - @Test - fun `publish of an empty map is a no-op`() = runTest { - catalog.publish(emptyMap()) - assertNull(catalog.loadAll()["never_published_name"]) - } -}