diff --git a/README.md b/README.md index ab7da7b..cc681ac 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 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). @@ -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 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_placeholder` +and use PlaceholderAPI with the entry's **id**: + +``` +%inkwell_fact_% +``` + +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 **Facts** — one document per `(entryId, groupId)`: @@ -139,6 +160,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/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/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. 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..25601b4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-cross-server-fact-placeholder-design.md @@ -0,0 +1,209 @@ +# Cross-server fact placeholder — design + +**Date:** 2026-06-27 +**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 + +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) +``` 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..ebc02a0 --- /dev/null +++ b/src/main/kotlin/fr/perrier/inkwell/FactPlaceholderResolver.kt @@ -0,0 +1,29 @@ +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_%`: 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 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 entryId = params.removePrefix(PREFIX) + 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/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/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/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt b/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt index 5dde352..0d07513 100644 --- a/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt +++ b/src/main/kotlin/fr/perrier/inkwell/InkwellPlugin.kt @@ -1,8 +1,11 @@ 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.FactSessionSync import fr.perrier.inkwell.storage.MongoFactStorage import fr.perrier.inkwell.storage.MongoFileSync @@ -23,6 +26,9 @@ class InkwellPlugin : JavaPlugin() { private lateinit var databaseConfig: DatabaseConfig private lateinit var snippetsFile: File + // 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) @@ -76,6 +82,9 @@ class InkwellPlugin : JavaPlugin() { logger.warning("FactStorage is not MongoFactStorage — per-player fact sync disabled") } } + + if (databaseConfig.factPlaceholderEnabled) startFactPlaceholder() + logger.info("Inkwell enabled") } @@ -94,17 +103,36 @@ class InkwellPlugin : JavaPlugin() { }, ticks, ticks) } + /** + * 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 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() { // 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") } diff --git a/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt b/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt index 151c298..0b0da7f 100644 --- a/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt +++ b/src/main/kotlin/fr/perrier/inkwell/config/DatabaseConfig.kt @@ -14,6 +14,16 @@ 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, + // 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 { fun from(config: FileConfiguration): DatabaseConfig { @@ -25,6 +35,11 @@ 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() }, + 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", filesCollection = section.getString("files_collection") ?: "files", 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 795c9e1..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 @@ -19,47 +17,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? = { FactCacheAccess.reflect(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 +102,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 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/main/resources/config.yml b/src/main/resources/config.yml index 6202531..1f2b6a3 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). @@ -20,6 +26,11 @@ storage: pages: true # snippets.yml configuration. snippets: true + # 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 # 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 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..76516ad --- /dev/null +++ b/src/test/kotlin/fr/perrier/inkwell/FactPlaceholderResolverTest.kt @@ -0,0 +1,49 @@ +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(cache: Map?) = + FactPlaceholderResolver(cacheProvider = { cache }, default = "0") + + @Test + 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 `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(emptyMap()) + assertEquals("0", r.resolve("fact_entry1", null)) + } + + @Test + fun `params not starting with fact_ returns null`() { + val r = resolver(emptyMap()) + assertNull(r.resolve("something_else", uuid)) + } + + @Test + fun `null cache returns the default`() { + val r = resolver(null) + assertEquals("0", r.resolve("fact_entry1", uuid)) + } +} 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/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)) + } +} 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 + } +}