diff --git a/documentation/docs/develop/02-extensions/04-entries/placeholder.mdx b/documentation/docs/develop/02-extensions/04-entries/placeholder.mdx new file mode 100644 index 0000000000..7c86661b53 --- /dev/null +++ b/documentation/docs/develop/02-extensions/04-entries/placeholder.mdx @@ -0,0 +1,44 @@ +import CodeSnippet from "@site/src/components/CodeSnippet"; + +# Placeholder +Entries can expose a placeholder. +The placeholders can be used by users or other plugins with the PlaceholderAPI. + +:::danger +Placeholder is an additional interface for an existing entry. It cannot be used on its own. +::: + +## Basic Usage +To just expose a single placeholder, extend your entry with `PlaceholderEntry`: + + + +This placeholder can be used with `%typewriter_%` and will return `Hello !` + +## Sub Placeholders +Besides just having a primary placeholder, entries can also have sub placeholders. +These can be literal strings which needs to be matched: + + + +Where the placeholder can be used in the following ways: + +| Placeholder | Result | +|------------|---------| +| `%typewriter_%` | `Standard text` | +| `%typewriter_:greet%` | `Hello, !` | +| `%typewriter_:greet:enthusiastic%` | `HEY HOW IS YOUR DAY, !` | + +But is can also have variables: + + + +Where the placeholder can be used in the following ways: + +| Placeholder | Result | +|------------|---------| +| `%typewriter_%` | `%typewriter_%` | +| `%typewriter_:bob%` | `Hello, bob!` | +| `%typewriter_:alice%` | `Hello, alice!` | + +Notice how the default placeholder no longer works, because we don't have a supplier for it anymore. diff --git a/documentation/docs/develop/02-extensions/07-api-changes/0.7.mdx b/documentation/docs/develop/02-extensions/07-api-changes/0.7.mdx new file mode 100644 index 0000000000..e6c20f7817 --- /dev/null +++ b/documentation/docs/develop/02-extensions/07-api-changes/0.7.mdx @@ -0,0 +1,41 @@ +--- +title: 0.7.X API Changes +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +# All API changes to 0.7.X + +The v0.7.X release contains the new Dynamic variable. +To learn how to use them, see [Dynamic Variables](/develop/extensions/entries/static/variable). + +## PlaceholderEntry Changes + + + + ```kotlin showLineNumbers + override fun display(player: Player?): String? { + return "Hello, ${player?.name ?: "World"}!" + } + ``` + + + ```kotlin showLineNumbers + override fun parser(): PlaceholderParser = placeholderParser { + supply { player -> + "Hello, ${player?.name ?: "World"}!" + } + } + ``` + + + +The placeholder now returns a parser instead of directly parsing. +This allows for more complex placeholders to be created. +With sub placeholders, for example. + +For example the `TimedFact` returns the fact value by default for fact `%typewriter_%`. +But if you want the time when the fact will expire you can use `%typewriter_:time:expires:relative%` +Where the `:time:expires:relative` is a sub placeholder. + diff --git a/documentation/plugins/code-snippets/snippets.json b/documentation/plugins/code-snippets/snippets.json index 79a2c73ae2..84fb438824 100644 --- a/documentation/plugins/code-snippets/snippets.json +++ b/documentation/plugins/code-snippets/snippets.json @@ -1,118 +1,130 @@ { + "initializer": { + "file": "src/main/kotlin/com/typewritermc/example/ExampleInitializer.kt", + "content": "import com.typewritermc.core.extension.Initializable\nimport com.typewritermc.core.extension.annotations.Initializer\n\n@Initializer\nobject ExampleInitializer : Initializable {\n override fun initialize() {\n // Do something when the extension is initialized\n }\n\n override fun shutdown() {\n // Do something when the extension is shutdown\n }\n}" + }, + "simple_placeholder_entry": { + "file": "src/main/kotlin/com/typewritermc/example/entries/ExamplePlaceholderEntry.kt", + "content": "class SimpleExamplePlaceholderEntry(\n override val id: String,\n override val name: String,\n) : PlaceholderEntry {\n override fun parser(): PlaceholderParser = placeholderParser {\n supply { player ->\n \"Hello, ${player?.name ?: \"World\"}!\"\n }\n }\n}" + }, + "literal_placeholder_entry": { + "file": "src/main/kotlin/com/typewritermc/example/entries/ExamplePlaceholderEntry.kt", + "content": " override fun parser(): PlaceholderParser = placeholderParser {\n literal(\"greet\") {\n literal(\"enthusiastic\") {\n supply { player ->\n \"HEY HOW IS YOUR DAY, ${player?.name ?: \"World\"}!\"\n }\n }\n supply { player ->\n \"Hello, ${player?.name ?: \"World\"}\"\n }\n }\n supply {\n \"Standard text\"\n }\n }" + }, + "string_placeholder_entry": { + "file": "src/main/kotlin/com/typewritermc/example/entries/ExamplePlaceholderEntry.kt", + "content": " override fun parser(): PlaceholderParser = placeholderParser {\n string(\"name\") { name ->\n supply {\n \"Hello, ${name()}!\"\n }\n }\n }" + }, "cinematic_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\cinematic\\ExampleCinematicEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/cinematic/ExampleCinematicEntry.kt", "content": "@Entry(\"example_cinematic\", \"An example cinematic entry\", Colors.BLUE, \"material-symbols:cinematic-blur\")\nclass ExampleCinematicEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n override val criteria: List = emptyList(),\n @Segments(Colors.BLUE, \"material-symbols:cinematic-blur\")\n val segments: List = emptyList(),\n) : CinematicEntry {\n override fun create(player: Player): CinematicAction {\n return ExampleCinematicAction(player, this)\n }\n}" }, "cinematic_segment_with_min_max": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\cinematic\\ExampleCinematicEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/cinematic/ExampleCinematicEntry.kt", "content": " @Segments(Colors.BLUE, \"material-symbols:cinematic-blur\")\n @InnerMin(Min(10))\n @InnerMax(Max(20))\n val segments: List = emptyList()," }, "cinematic_create_actions": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\cinematic\\ExampleCinematicEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/cinematic/ExampleCinematicEntry.kt", "content": " // This will be used when the cinematic is normally displayed to the player.\n override fun create(player: Player): CinematicAction {\n return DefaultCinematicAction(player, this)\n }\n\n // This is used during content mode to display the cinematic to the player.\n // It may be null to not show it during simulation.\n override fun createSimulating(player: Player): CinematicAction? {\n return SimulatedCinematicAction(player, this)\n }\n\n // This is used during content mode to record the cinematic.\n // It may be null to not record it during simulation.\n override fun createRecording(player: Player): CinematicAction? {\n return RecordingCinematicAction(player, this)\n }" }, "cinematic_segment": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\cinematic\\ExampleCinematicEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/cinematic/ExampleCinematicEntry.kt", "content": "data class ExampleSegment(\n override val startFrame: Int = 0,\n override val endFrame: Int = 0,\n) : Segment" }, "cinematic_action": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\cinematic\\ExampleCinematicEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/cinematic/ExampleCinematicEntry.kt", "content": "class ExampleCinematicAction(\n val player: Player,\n val entry: ExampleCinematicEntry,\n) : CinematicAction {\n override suspend fun setup() {\n // Initialize variables, spawn entities, etc.\n }\n\n override suspend fun tick(frame: Int) {\n val segment = entry.segments activeSegmentAt frame\n // Can be null if no segment is active\n\n // The `frame` parameter is not necessarily next frame: `frame != old(frame)+1`\n\n // Execute tick logic for the segment\n }\n\n override suspend fun teardown() {\n // Remove entities, etc.\n }\n\n override fun canFinish(frame: Int): Boolean = entry.segments canFinishAt frame\n}" }, "cinematic_simple_action": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\cinematic\\ExampleCinematicEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/cinematic/ExampleCinematicEntry.kt", "content": "class ExampleSimpleCinematicAction(\n val player: Player,\n entry: ExampleCinematicEntry,\n) : SimpleCinematicAction() {\n override val segments: List = entry.segments\n\n override suspend fun startSegment(segment: ExampleSegment) {\n super.startSegment(segment) // Keep this\n // Called when a segment starts\n }\n\n override suspend fun tickSegment(segment: ExampleSegment, frame: Int) {\n super.tickSegment(segment, frame) // Keep this\n // Called every tick while the segment is active\n // Will always be called after startSegment and never after stopSegment\n\n // The `frame` parameter is not necessarily next frame: `frame != old(frame)+1`\n }\n\n override suspend fun stopSegment(segment: ExampleSegment) {\n super.stopSegment(segment) // Keep this\n // Called when the segment ends\n // Will also be called if the cinematic is stopped early\n }\n}" }, "audience_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\manifest\\ExampleAudienceEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/manifest/ExampleAudienceEntry.kt", "content": "@Entry(\"example_audience\", \"An example audience entry.\", Colors.GREEN, \"material-symbols:chat-rounded\")\nclass ExampleAudienceEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n) : AudienceEntry {\n override fun display(): AudienceDisplay {\n return ExampleAudienceDisplay()\n }\n}" }, "audience_display": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\manifest\\ExampleAudienceEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/manifest/ExampleAudienceEntry.kt", "content": "class ExampleAudienceDisplay : AudienceDisplay() {\n override fun initialize() {\n // This is called when the first player is added to the audience.\n super.initialize()\n // Do something when the audience is initialized\n }\n\n override fun onPlayerAdd(player: Player) {\n // Do something when a player gets added to the audience\n }\n\n override fun onPlayerRemove(player: Player) {\n // Do something when a player gets removed from the audience\n }\n\n override fun dispose() {\n super.dispose()\n // Do something when the audience is disposed\n // It will always call onPlayerRemove for all players.\n // So no player cleanup is needed here.\n }\n}" }, "tickable_audience_display": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\manifest\\ExampleAudienceEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/manifest/ExampleAudienceEntry.kt", "content": "// highlight-next-line\nclass TickableAudienceDisplay : AudienceDisplay(), TickableDisplay {\n override fun onPlayerAdd(player: Player) {}\n override fun onPlayerRemove(player: Player) {}\n\n // highlight-start\n override fun tick() {\n // Do something when the audience is ticked\n players.forEach { player ->\n // Do something with the player\n }\n\n // This is running asynchronously\n // If you need to do something on the main thread\n ThreadType.SYNC.launch {\n // Though this will run a tick later, to sync with the bukkit scheduler.\n }\n }\n // highlight-end\n}" }, "audience_display_with_events": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\manifest\\ExampleAudienceEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/manifest/ExampleAudienceEntry.kt", "content": "class AudienceDisplayWithEvents : AudienceDisplay() {\n override fun onPlayerAdd(player: Player) {}\n override fun onPlayerRemove(player: Player) {}\n\n // highlight-start\n @EventHandler\n fun onSomeEvent(event: SomeBukkitEvent) {\n // Do something when the event is triggered\n // This will trigger for all players, not just the ones in the audience.\n // So we need to check if the player is in the audience.\n if (event.player in this) {\n // Do something with the player\n }\n }\n // highlight-end\n}" }, "artifact_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\static\\ExampleArtifactEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/static/ExampleArtifactEntry.kt", "content": "@Entry(\"example_artifact\", \"An example artifact entry.\", Colors.BLUE, \"material-symbols:home-storage-rounded\")\nclass ExampleArtifactEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n override val artifactId: String = \"\",\n) : ArtifactEntry" }, "artifact_access": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\static\\ExampleArtifactEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/static/ExampleArtifactEntry.kt", "content": "suspend fun accessArtifactData(ref: Ref) {\n val assetManager = KoinJavaComponent.get(AssetManager::class.java)\n val entry = ref.get() ?: return\n val content: String? = assetManager.fetchAsset(entry)\n // Do something with the content\n}" }, "asset_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\static\\ExampleAssetEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/static/ExampleAssetEntry.kt", "content": "@Entry(\"example_asset\", \"An example asset entry.\", Colors.BLUE, \"material-symbols:home-storage-rounded\")\nclass ExampleAssetEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n override val path: String = \"\",\n) : AssetEntry" }, "asset_access": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\static\\ExampleAssetEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/static/ExampleAssetEntry.kt", "content": "suspend fun accessAssetData(ref: Ref) {\n val assetManager = KoinJavaComponent.get(AssetManager::class.java)\n val entry = ref.get() ?: return\n val content: String? = assetManager.fetchAsset(entry)\n // Do something with the content\n}" }, "sound_id_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\static\\ExampleSoundIdEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/static/ExampleSoundIdEntry.kt", "content": "@Entry(\"example_sound\", \"An example sound entry.\", Colors.BLUE, \"icon-park-solid:volume-up\")\nclass ExampleSoundIdEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n override val soundId: String = \"\",\n) : SoundIdEntry" }, "sound_source_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\static\\ExampleSoundSourceEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/static/ExampleSoundSourceEntry.kt", "content": "@Entry(\"example_sound_source\", \"An example sound source entry.\", Colors.BLUE, \"ic:round-spatial-audio-off\")\nclass ExampleSoundSourceEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n) : SoundSourceEntry {\n override fun getEmitter(player: Player): SoundEmitter {\n // Return the emitter that should be used for the sound.\n // An entity should be provided.\n return SoundEmitter(player.entityId)\n }\n}" }, "speaker_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\static\\ExampleSpeakerEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/static/ExampleSpeakerEntry.kt", "content": "@Entry(\"example_speaker\", \"An example speaker entry.\", Colors.BLUE, \"ic:round-spatial-audio-off\")\nclass ExampleSpeakerEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n override val displayName: Var = ConstVar(\"\"),\n override val sound: Sound = Sound.EMPTY,\n) : SpeakerEntry" }, "variable_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\static\\ExampleVariableEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/static/ExampleVariableEntry.kt", "content": "@Entry(\"example_variable\", \"An example variable entry.\", Colors.GREEN, \"mdi:code-tags\")\nclass ExampleVariableEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n) : VariableEntry {\n override fun get(context: VarContext): T {\n val player = context.player\n val klass = context.klass\n\n TODO(\"Do something with the player and the klass\")\n }\n}" }, "variable_entry_with_data": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\static\\ExampleVariableEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/static/ExampleVariableEntry.kt", "content": "@Entry(\"example_variable_with_data\", \"An example variable entry with data.\", Colors.GREEN, \"mdi:code-tags\")\n// Register the variable data associated with this variable.\n@VariableData(ExampleVariableWithData::class)\nclass ExampleVariableWithDataEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n // This data will be the same for all uses of this variable.\n val someString: String = \"\",\n) : VariableEntry {\n override fun get(context: VarContext): T {\n val player = context.player\n val klass = context.klass\n this.someString\n val data = context.getData() ?: throw IllegalStateException(\"Could not find data for ${context.klass}, data: ${context.data}\")\n\n TODO(\"Do something with the player, the klass, and the data\")\n }\n}\n\nclass ExampleVariableWithData(\n // This data can change at the place where the variable is used.\n val otherInfo: Int = 0,\n)" }, "generic_variable_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\static\\ExampleVariableEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/static/ExampleVariableEntry.kt", "content": "@Entry(\"example_generic_variable\", \"An example generic variable entry.\", Colors.GREEN, \"mdi:code-tags\")\nclass ExampleGenericVariableEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n // We determine how to parse this during runtime.\n val generic: Generic = Generic.Empty,\n) : VariableEntry {\n override fun get(context: VarContext): T {\n val player = context.player\n val klass = context.klass\n\n // Parse the generic data to the correct type.\n val data = generic.get(klass)\n\n TODO(\"Do something with the player, the klass, and the generic\")\n }\n}\n\nclass ExampleGenericVariableData(\n // Generic data will always be the same as the generic type in the variable.\n val otherGeneric: Generic,\n)" }, "constraint_variable_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\static\\ExampleVariableEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/static/ExampleVariableEntry.kt", "content": "@Entry(\"example_constraint_variable\", \"An example constraint variable entry.\", Colors.GREEN, \"mdi:code-tags\")\n@GenericConstraint(String::class)\n@GenericConstraint(Int::class)\nclass ExampleConstraintVariableEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n // We determine how to parse this during runtime.\n val generic: Generic = Generic.Empty,\n) : VariableEntry {\n override fun get(context: VarContext): T {\n val player = context.player\n // This can only be a String or an Int.\n val klass = context.klass\n\n // Parse the generic data to the correct type.\n val data = generic.get(klass)\n\n TODO(\"Do something with the player, the klass, and the generic\")\n }\n}" }, "variable_usage": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\static\\ExampleVariableEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/static/ExampleVariableEntry.kt", "content": "@Entry(\"example_action_using_variable\", \"An example action that uses a variable.\", Colors.RED, \"material-symbols:touch-app-rounded\")\nclass ExampleActionUsingVariableEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n override val triggers: List> = emptyList(),\n override val criteria: List = emptyList(),\n override val modifiers: List = emptyList(),\n val someString: Var = ConstVar(\"\"),\n val someInt: Var = ConstVar(0),\n) : ActionEntry {\n override fun execute(player: Player) {\n val someString = someString.get(player)\n val someInt = someInt.get(player)\n\n // Do something with the variables\n }\n}" }, "action_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\trigger\\ExampleActionEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/trigger/ExampleActionEntry.kt", "content": "@Entry(\"example_action\", \"An example action entry.\", Colors.RED, \"material-symbols:touch-app-rounded\")\nclass ExampleActionEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n override val criteria: List = emptyList(),\n override val modifiers: List = emptyList(),\n override val triggers: List> = emptyList(),\n) : ActionEntry {\n override fun execute(player: Player) {\n super.execute(player) // This will apply all the modifiers.\n // Do something with the player\n }\n}" }, "custom_triggering_action_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\trigger\\ExampleCustomTriggeringActionEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/trigger/ExampleCustomTriggeringActionEntry.kt", "content": "@Entry(\n \"example_custom_triggering_action\",\n \"An example custom triggering entry.\",\n Colors.RED,\n \"material-symbols:touch-app-rounded\"\n)\nclass ExampleCustomTriggeringActionEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n override val criteria: List = emptyList(),\n override val modifiers: List = emptyList(),\n @SerializedName(\"triggers\")\n override val customTriggers: List> = emptyList(),\n) : CustomTriggeringActionEntry {\n override fun execute(player: Player) {\n super.execute(player) // This will apply the modifiers.\n // Do something with the player\n player.triggerCustomTriggers() // Can be called later to trigger the next entries.\n }\n}" }, "dialogue_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\trigger\\ExampleDialogueEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/trigger/ExampleDialogueEntry.kt", "content": "@Entry(\"example_dialogue\", \"An example dialogue entry.\", Colors.BLUE, \"material-symbols:chat-rounded\")\nclass ExampleDialogueEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n override val criteria: List = emptyList(),\n override val modifiers: List = emptyList(),\n override val triggers: List> = emptyList(),\n override val speaker: Ref = emptyRef(),\n @MultiLine\n @Placeholder\n @Colored\n @Help(\"The text to display to the player.\")\n val text: String = \"\",\n) : DialogueEntry" }, "dialogue_messenger": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\trigger\\ExampleDialogueEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/trigger/ExampleDialogueEntry.kt", "content": "@Messenger(ExampleDialogueEntry::class)\nclass ExampleDialogueDialogueMessenger(player: Player, entry: ExampleDialogueEntry) :\n DialogueMessenger(player, entry) {\n\n companion object : MessengerFilter {\n override fun filter(player: Player, entry: DialogueEntry): Boolean = true\n }\n\n // Called every game tick (20 times per second).\n // The cycle is a parameter that is incremented every tick, starting at 0.\n override fun tick(context: TickContext) {\n super.tick(context)\n if (state != MessengerState.RUNNING) return\n\n player.sendMessage(\"${entry.speakerDisplayName}: ${entry.text}\".parsePlaceholders(player).asMini())\n\n // When we want the dialogue to end, we can set the state to FINISHED.\n state = MessengerState.FINISHED\n }\n}" }, "event_entry": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\trigger\\ExampleEventEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/trigger/ExampleEventEntry.kt", "content": "@Entry(\"example_event\", \"An example event entry.\", Colors.YELLOW, \"material-symbols:bigtop-updates\")\nclass ExampleEventEntry(\n override val id: String = \"\",\n override val name: String = \"\",\n override val triggers: List> = emptyList(),\n) : EventEntry" }, "event_entry_listener": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\entries\\trigger\\ExampleEventEntry.kt", + "file": "src/main/kotlin/com/typewritermc/example/entries/trigger/ExampleEventEntry.kt", "content": "// Must be scoped to be public\n@EntryListener(ExampleEventEntry::class)\nfun onEvent(event: SomeBukkitEvent, query: Query) {\n // Do something\n val entries = query.find() // Find all the entries of this type, for more information see the Query section\n // Do something with the entries, for example trigger them\n entries triggerAllFor event.player\n}" - }, - "initializer": { - "file": "src\\main\\kotlin\\com\\typewritermc\\example\\ExampleInitializer.kt", - "content": "import com.typewritermc.core.extension.Initializable\nimport com.typewritermc.core.extension.annotations.Initializer\n\n@Initializer\nobject ExampleInitializer : Initializable {\n override fun initialize() {\n // Do something when the extension is initialized\n }\n\n override fun shutdown() {\n // Do something when the extension is shutdown\n }\n}" } } \ No newline at end of file diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index ac2d7b17e1..5356627410 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -40,7 +40,11 @@ subprojects { dependencies { api("io.insert-koin:koin-core:3.5.6") compileOnly("com.google.code.gson:gson:2.11.0") - testImplementation(kotlin("test")) + + val kotestVersion = "5.7.2" + testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") + testImplementation("io.kotest:kotest-property:$kotestVersion") } diff --git a/engine/engine-core/src/main/kotlin/com/typewritermc/core/utils/DurationFormat.kt b/engine/engine-core/src/main/kotlin/com/typewritermc/core/utils/DurationFormat.kt new file mode 100644 index 0000000000..ee222ba7a0 --- /dev/null +++ b/engine/engine-core/src/main/kotlin/com/typewritermc/core/utils/DurationFormat.kt @@ -0,0 +1,20 @@ +package com.typewritermc.core.utils + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +fun Duration.formatCompact(): String { + val days = this.inWholeDays + val hours = (this - days.days).inWholeHours + val minutes = (this - days.days - hours.hours).inWholeMinutes + val seconds = (this - days.days - hours.hours - minutes.minutes).inWholeSeconds + + return buildString { + if (days > 0) append("${days}d ") + if (hours > 0) append("${hours}h ") + if (minutes > 0) append("${minutes}m ") + if (seconds > 0 || this.isEmpty()) append("${seconds}s") + }.trim() +} \ No newline at end of file diff --git a/engine/engine-paper/build.gradle.kts b/engine/engine-paper/build.gradle.kts index b4e2829e1c..12eb6268be 100644 --- a/engine/engine-paper/build.gradle.kts +++ b/engine/engine-paper/build.gradle.kts @@ -62,7 +62,7 @@ dependencies { compileOnly("me.clip:placeholderapi:2.11.6") compileOnlyApi("org.geysermc.floodgate:api:2.2.0-SNAPSHOT") - testImplementation("org.junit.jupiter:junit-jupiter:5.9.0") + testImplementation("org.mockbukkit.mockbukkit:mockbukkit-v1.21:4.3.1") } tasks.withType { diff --git a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/TypewriterPaperPlugin.kt b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/TypewriterPaperPlugin.kt index c66228ab99..c0b158ec9f 100644 --- a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/TypewriterPaperPlugin.kt +++ b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/TypewriterPaperPlugin.kt @@ -172,6 +172,10 @@ class TypewriterPaperPlugin : KotlinPlugin(), KoinComponent { get().load() get().load() CustomCommandEntry.registerAll() + + if (server.pluginManager.getPlugin("PlaceholderAPI") != null) { + PlaceholderExpansion.load() + } } suspend fun unload() { @@ -184,6 +188,10 @@ class TypewriterPaperPlugin : KotlinPlugin(), KoinComponent { get().unload() get().unload() + if (server.pluginManager.getPlugin("PlaceholderAPI") != null) { + PlaceholderExpansion.unload() + } + // Needs to be last, as it will unload the classLoader get().unload() } diff --git a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/PlaceholderEntry.kt b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/PlaceholderEntry.kt index 3c4a3e946a..d667b42a88 100644 --- a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/PlaceholderEntry.kt +++ b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/PlaceholderEntry.kt @@ -2,9 +2,173 @@ package com.typewritermc.engine.paper.entry import com.typewritermc.core.entries.Entry import com.typewritermc.core.extension.annotations.Tags +import com.typewritermc.core.utils.failure +import com.typewritermc.core.utils.ok import org.bukkit.entity.Player +import java.util.* +import kotlin.reflect.KClass +import kotlin.reflect.cast @Tags("placeholder") interface PlaceholderEntry : Entry { - fun display(player: Player?): String? + fun parser(): PlaceholderParser } + +interface PlaceholderSupplier { + fun supply(context: ParsingContext): String? +} + +class ParsingContext( + val player: Player?, + private val arguments: Map, Any> +) { + fun getArgument(reference: ArgumentReference): T { + val value = arguments[reference] + return reference.type.cast(value) + } + + fun hasArgument(reference: ArgumentReference): Boolean { + return arguments.containsKey(reference) + } + + operator fun ArgumentReference.invoke(): T { + return getArgument(this) + } +} + +interface PlaceholderArgument { + fun parse(player: Player?, argument: String): Result +} + +class ArgumentReference( + val id: String = UUID.randomUUID().toString(), + val type: KClass, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ArgumentReference<*>) return false + + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + return id.hashCode() + } + + override fun toString(): String { + return "ArgumentReference(id='$id', type=$type)" + } +} + +sealed interface PlaceholderNode + +class SupplierNode(val supplier: PlaceholderSupplier) : PlaceholderNode + +class ArgumentNode( + val name: String, + val reference: ArgumentReference, + val argument: PlaceholderArgument, + val children: List +) : PlaceholderNode + +class PlaceholderParser( + val nodes: List, +) { + fun parse(player: Player?, arguments: List): String? { + val parsedArguments = mutableMapOf, Any>() + var currentNodes = nodes + for (argument in arguments) { + val nextNodes = mutableListOf() + for (node in currentNodes) { + when (node) { + is SupplierNode -> {} + is ArgumentNode<*> -> { + val result = node.argument.parse(player, argument) + if (result.isSuccess) { + parsedArguments[node.reference] = result.getOrThrow() + nextNodes.addAll(node.children) + } + } + } + } + if (nextNodes.isEmpty()) { + return null + } + currentNodes = nextNodes + } + + val context = ParsingContext(player, parsedArguments) + val suppliers = currentNodes.filterIsInstance() + if (suppliers.isEmpty()) { + return null + } + + return suppliers.first().supplier.supply(context) + } +} + +class PlaceholderNodeBuilder { + internal val nodes = mutableListOf() + operator fun plusAssign(node: PlaceholderNode) { + nodes += node + } +} + +fun placeholderParser(builder: PlaceholderNodeBuilder.() -> Unit): PlaceholderParser { + val nodes = PlaceholderNodeBuilder().apply(builder).nodes + return PlaceholderParser(nodes) +} + +fun PlaceholderNodeBuilder.include(parser: PlaceholderParser) { + nodes.addAll(parser.nodes) +} + +fun PlaceholderNodeBuilder.supply(supplier: ParsingContext.(Player?) -> String?) { + this += SupplierNode(object : PlaceholderSupplier { + override fun supply(context: ParsingContext): String? { + return supplier(context, context.player) + } + }) +} + +fun PlaceholderNodeBuilder.supplyPlayer(supplier: ParsingContext.(Player) -> String?) { + this += SupplierNode(object : PlaceholderSupplier { + override fun supply(context: ParsingContext): String? { + return supplier(context, context.player ?: return null) + } + }) +} + +typealias ArgumentBuilder = PlaceholderNodeBuilder.(ArgumentReference) -> Unit + +fun PlaceholderNodeBuilder.argument( + name: String, + type: KClass, + argument: PlaceholderArgument, + builder: ArgumentBuilder, +) { + val reference = ArgumentReference(type = type) + val children = PlaceholderNodeBuilder().apply { builder(reference) }.nodes + this += ArgumentNode(name, reference, argument, children) +} + +fun PlaceholderNodeBuilder.literal(name: String, builder: PlaceholderNodeBuilder.() -> Unit) = + argument(name, String::class, LiteralArgument(name)) { builder() } + +class LiteralArgument(val name: String) : PlaceholderArgument { + override fun parse(player: Player?, argument: String): Result { + if (argument != name) return failure("Literal '$name' didn't match argument '$argument'") + return ok(argument) + } +} + +fun PlaceholderNodeBuilder.string(name: String, builder: ArgumentBuilder) = + argument(name, String::class, StringArgument, builder) + +object StringArgument : PlaceholderArgument { + override fun parse(player: Player?, argument: String): Result { + return ok(argument) + } +} \ No newline at end of file diff --git a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/EntityEntry.kt b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/EntityEntry.kt index 982deded9e..7e24c1d3cf 100644 --- a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/EntityEntry.kt +++ b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/EntityEntry.kt @@ -5,11 +5,8 @@ import com.typewritermc.core.entries.Ref import com.typewritermc.core.entries.ref import com.typewritermc.core.extension.annotations.* import com.typewritermc.core.utils.point.Position -import com.typewritermc.engine.paper.entry.ManifestEntry -import com.typewritermc.engine.paper.entry.PlaceholderEntry +import com.typewritermc.engine.paper.entry.* import com.typewritermc.engine.paper.entry.entity.* -import com.typewritermc.engine.paper.entry.findDisplay -import com.typewritermc.engine.paper.entry.inAudience import com.typewritermc.engine.paper.utils.Sound import org.bukkit.entity.Player import kotlin.reflect.KClass @@ -24,7 +21,9 @@ interface SpeakerEntry : PlaceholderEntry { @Help("The sound that will be played when the entity speaks.") val sound: Sound - override fun display(player: Player?): String? = displayName.get(player) + override fun parser(): PlaceholderParser = placeholderParser { + supply { player -> displayName.get(player) } + } } /** diff --git a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/FactEntry.kt b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/FactEntry.kt index 620041dc18..0fe2738a02 100644 --- a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/FactEntry.kt +++ b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/FactEntry.kt @@ -4,15 +4,23 @@ import com.typewritermc.core.entries.Ref import com.typewritermc.core.extension.annotations.Help import com.typewritermc.core.extension.annotations.MultiLine import com.typewritermc.core.extension.annotations.Tags -import com.typewritermc.engine.paper.entry.PlaceholderEntry -import com.typewritermc.engine.paper.entry.StaticEntry +import com.typewritermc.core.utils.formatCompact +import com.typewritermc.engine.paper.entry.* import com.typewritermc.engine.paper.facts.FactData import com.typewritermc.engine.paper.facts.FactDatabase import com.typewritermc.engine.paper.facts.FactId +import com.typewritermc.engine.paper.loader.serializers.DurationSerializer import lirand.api.extensions.server.server +import org.apache.commons.lang3.time.DurationFormatUtils +import org.apache.commons.lang3.time.DurationUtils import org.bukkit.entity.Player import org.koin.java.KoinJavaComponent.get +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter import java.util.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds @Tags("fact") interface FactEntry : StaticEntry { @@ -31,7 +39,7 @@ interface FactEntry : StaticEntry { entry.groupId(player) ?: return null } else { // If no group entry is set, we assume that the player is the group for backwards compatibility - com.typewritermc.engine.paper.entry.entries.GroupId(player.uniqueId) + GroupId(player.uniqueId) } return FactId(id, groupId) @@ -61,14 +69,31 @@ interface ReadableFactEntry : FactEntry, PlaceholderEntry { */ fun readSinglePlayer(player: Player): FactData - override fun display(player: Player?): String? { - if (player == null) return null - return readForPlayersGroup(player).value.toString() + override fun parser(): PlaceholderParser = placeholderParser { + literal("time") { + literal("lastUpdated") { + literal("relative") { + supplyPlayer { player -> + val lastUpdate = readForPlayersGroup(player).lastUpdate + val now = LocalDateTime.now() + val difference = (now.toEpochSecond(ZoneOffset.UTC) - lastUpdate.toEpochSecond(ZoneOffset.UTC)).seconds + + "${difference.formatCompact()} ago" + } + } + supplyPlayer { player -> + readForPlayersGroup(player).lastUpdate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + } + } + } + supplyPlayer { player -> + readForPlayersGroup(player).value.toString() + } } } @Tags("writable-fact") -interface WritableFactEntry : com.typewritermc.engine.paper.entry.entries.FactEntry { +interface WritableFactEntry : FactEntry { fun write(player: Player, value: Int) { val factId = identifier(player) ?: return write(factId, value) @@ -78,10 +103,10 @@ interface WritableFactEntry : com.typewritermc.engine.paper.entry.entries.FactEn } @Tags("cachable-fact") -interface CachableFactEntry : com.typewritermc.engine.paper.entry.entries.ReadableFactEntry, - com.typewritermc.engine.paper.entry.entries.WritableFactEntry { +interface CachableFactEntry : ReadableFactEntry, + WritableFactEntry { - override fun readForGroup(groupId: com.typewritermc.engine.paper.entry.entries.GroupId): FactData { + override fun readForGroup(groupId: GroupId): FactData { return read(FactId(id, groupId)) } @@ -104,11 +129,11 @@ interface CachableFactEntry : com.typewritermc.engine.paper.entry.entries.Readab } @Tags("persistable-fact") -interface PersistableFactEntry : com.typewritermc.engine.paper.entry.entries.CachableFactEntry { +interface PersistableFactEntry : CachableFactEntry { fun canPersist(id: FactId, data: FactData): Boolean = true } @Tags("expirable-fact") -interface ExpirableFactEntry : com.typewritermc.engine.paper.entry.entries.CachableFactEntry { +interface ExpirableFactEntry : CachableFactEntry { fun hasExpired(id: FactId, data: FactData): Boolean = false } \ No newline at end of file diff --git a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/LinesEntry.kt b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/LinesEntry.kt index 20692aa4e1..2de8d92ed8 100644 --- a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/LinesEntry.kt +++ b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/LinesEntry.kt @@ -2,7 +2,7 @@ package com.typewritermc.engine.paper.entry.entries import com.typewritermc.core.entries.PriorityEntry import com.typewritermc.core.extension.annotations.Tags -import com.typewritermc.engine.paper.entry.PlaceholderEntry +import com.typewritermc.engine.paper.entry.* import com.typewritermc.engine.paper.extensions.placeholderapi.parsePlaceholders import org.bukkit.entity.Player import kotlin.reflect.KClass @@ -15,7 +15,9 @@ interface LinesEntry : EntityData, AudienceEntry, PlaceholderEntr */ fun lines(player: Player): String - override fun display(player: Player?): String? = player?.let { lines(it) } + override fun parser(): PlaceholderParser = placeholderParser { + supplyPlayer { player -> lines(player) } + } override fun type(): KClass = LinesProperty::class override fun build(player: Player): LinesProperty = LinesProperty(lines(player)) diff --git a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/QuestEntry.kt b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/QuestEntry.kt index bd689660f1..f6efd5335c 100644 --- a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/QuestEntry.kt +++ b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/QuestEntry.kt @@ -32,7 +32,14 @@ interface QuestEntry : AudienceFilterEntry, PlaceholderEntry { val facts: List> get() = emptyList() fun questStatus(player: Player): QuestStatus - override fun display(player: Player?): String = displayName.get(player)?.parsePlaceholders(player) ?: "" + fun display(player: Player): String { + return displayName.get(player).parsePlaceholders(player) + } + + override fun parser(): PlaceholderParser = placeholderParser { + supplyPlayer { player -> display(player) } + } + override fun display(): AudienceFilter = QuestAudienceFilter( ref() ) @@ -73,7 +80,7 @@ interface ObjectiveEntry : AudienceFilterEntry, PlaceholderEntry, PriorityEntry ) } - override fun display(player: Player?): String { + fun display(player: Player?): String { val text = when { player == null -> inactiveObjectiveDisplay criteria.matches(player) -> showingObjectiveDisplay @@ -83,6 +90,10 @@ interface ObjectiveEntry : AudienceFilterEntry, PlaceholderEntry, PriorityEntry return text.asMiniWithResolvers(parsed("display", display)).asMini().parsePlaceholders(player) } + + override fun parser(): PlaceholderParser = placeholderParser { + supply { player -> display(player) } + } } diff --git a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/SidebarEntry.kt b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/SidebarEntry.kt index d75bafb77b..fa72dcffc6 100644 --- a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/SidebarEntry.kt +++ b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/SidebarEntry.kt @@ -33,10 +33,14 @@ interface SidebarEntry : AudienceFilterEntry, PlaceholderEntry, PriorityEntry { @Placeholder val title: Var - override fun display(player: Player?): String? { + fun display(player: Player?): String { return title.get(player)?.parsePlaceholders(player) ?: "" } + override fun parser(): PlaceholderParser = placeholderParser { + supply { player -> display(player) } + } + override fun display(): AudienceFilter = SidebarFilter(ref()) { player -> PlayerSidebarDisplay(player, SidebarFilter::class, ref()) } @@ -66,7 +70,7 @@ private class PlayerSidebarDisplay( override fun initialize() { super.initialize() val sidebar = ref.get() ?: return - val title = sidebar.display(player) ?: "" + val title = sidebar.display(player) createSidebar(title) } @@ -80,7 +84,7 @@ private class PlayerSidebarDisplay( super.tick() val sidebar = ref.get() ?: return - val title = sidebar.display(player) ?: "" + val title = sidebar.display(player) val lines = lines .filter { player.inAudience(it) } diff --git a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/SoundEntry.kt b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/SoundEntry.kt index 02b0806731..97142a8632 100644 --- a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/SoundEntry.kt +++ b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/entry/entries/SoundEntry.kt @@ -1,8 +1,7 @@ package com.typewritermc.engine.paper.entry.entries import com.typewritermc.core.extension.annotations.Tags -import com.typewritermc.engine.paper.entry.PlaceholderEntry -import com.typewritermc.engine.paper.entry.StaticEntry +import com.typewritermc.engine.paper.entry.* import net.kyori.adventure.sound.Sound import org.bukkit.entity.Player @@ -10,7 +9,9 @@ import org.bukkit.entity.Player interface SoundIdEntry : StaticEntry, PlaceholderEntry { val soundId: String - override fun display(player: Player?): String? = soundId + override fun parser(): PlaceholderParser = placeholderParser { + supply { soundId } + } } @Tags("sound_source") diff --git a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/extensions/placeholderapi/PlaceholderExpansion.kt b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/extensions/placeholderapi/PlaceholderExpansion.kt index 335b158acc..7b6da3a8be 100644 --- a/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/extensions/placeholderapi/PlaceholderExpansion.kt +++ b/engine/engine-paper/src/main/kotlin/com/typewritermc/engine/paper/extensions/placeholderapi/PlaceholderExpansion.kt @@ -1,7 +1,9 @@ package com.typewritermc.engine.paper.extensions.placeholderapi import com.typewritermc.core.entries.Query +import com.typewritermc.core.utils.Reloadable import com.typewritermc.engine.paper.entry.PlaceholderEntry +import com.typewritermc.engine.paper.entry.PlaceholderParser import lirand.api.extensions.server.server import me.clip.placeholderapi.PlaceholderAPI import me.clip.placeholderapi.expansion.PlaceholderExpansion @@ -14,11 +16,13 @@ import org.bukkit.plugin.Plugin import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap private val noneTracked by snippet("quest.tracked.none", "None tracked") -object PlaceholderExpansion : PlaceholderExpansion(), KoinComponent { +object PlaceholderExpansion : PlaceholderExpansion(), KoinComponent, Reloadable { private val plugin: Plugin by inject() override fun getIdentifier(): String = "typewriter" @@ -28,6 +32,10 @@ object PlaceholderExpansion : PlaceholderExpansion(), KoinComponent { override fun persist(): Boolean = true + override fun load() = cachedParsers.clear() + override fun unload() = cachedParsers.clear() + + private val cachedParsers: ConcurrentHashMap = ConcurrentHashMap() override fun onPlaceholderRequest(player: Player?, params: String): String? { if (params == "tracked_quest") { if (player == null) return null @@ -40,8 +48,15 @@ object PlaceholderExpansion : PlaceholderExpansion(), KoinComponent { .ifBlank { noneTracked } } - val entry: PlaceholderEntry = Query.findById(params) ?: Query.findByName(params) ?: return null - return entry.display(player) + val parts = params.split(':') + val id = parts[0] + + val parser = cachedParsers.getOrPut(id) { + val entry: PlaceholderEntry = Query.findById(id) ?: Query.findByName(id) ?: return null + entry.parser() + } + + return parser.parse(player, parts.subList(1, parts.size)) } } diff --git a/engine/engine-paper/src/test/kotlin/com/typewritermc/utils/DurationParserTest.kt b/engine/engine-paper/src/test/kotlin/com/typewritermc/utils/DurationParserTest.kt index 9598315dae..e6885fd4fa 100644 --- a/engine/engine-paper/src/test/kotlin/com/typewritermc/utils/DurationParserTest.kt +++ b/engine/engine-paper/src/test/kotlin/com/typewritermc/utils/DurationParserTest.kt @@ -1,59 +1,43 @@ package com.typewritermc.utils import com.typewritermc.engine.paper.utils.DurationParser +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test -internal class DurationParserTest { - - @Test - fun `When a single duration is provided expect it to be parsed correctly`() { - Assertions.assertEquals(1, DurationParser.parse("1ms").inWholeMilliseconds) - Assertions.assertEquals(123, DurationParser.parse("123sec").inWholeSeconds) - Assertions.assertEquals(52, DurationParser.parse("52 m").inWholeMinutes) - Assertions.assertEquals(23, DurationParser.parse("23hr").inWholeHours) - Assertions.assertEquals(31, DurationParser.parse("31d").inWholeDays) - Assertions.assertEquals(12, DurationParser.parse("12 w").inWholeDays / 7) - Assertions.assertEquals(5, DurationParser.parse("5 months").inWholeDays / 30) - Assertions.assertEquals(2, DurationParser.parse("2yr").inWholeDays / 365) - } - - @Test - fun `When multiple durations are provided expect the sum of the durations as result`() { - Assertions.assertEquals( - 123 + 52 * 60 + 23 * 60 * 60 + 31 * 24 * 60 * 60 + 12 * 7 * 24 * 60 * 60 + 5 * 30 * 24 * 60 * 60 + 2 * 365 * 24 * 60 * 60, - DurationParser.parse("123sec 52 m 23hr 31d 12 w 5 months 2yr").inWholeSeconds - ) - - Assertions.assertEquals( - 60 + 20, - DurationParser.parse("1 hr 20 mins").inWholeMinutes - ) - - Assertions.assertEquals( - 60 + 20, - DurationParser.parse("1h20m0s").inWholeMinutes - ) - } - - @Test - fun `When a duration string contains other accepted characters expect it to parse`() { - Assertions.assertEquals( - 27681, - DurationParser.parse("27,681 ms").inWholeMilliseconds - ) - - Assertions.assertEquals( - 27681, - DurationParser.parse("27_681 milliseconds").inWholeMilliseconds - ) - } - - @Test - fun `When an empty duration string is given expect the resulting duration to be zero`() { - Assertions.assertEquals( - 0, - DurationParser.parse("").inWholeSeconds - ) - } -} \ No newline at end of file +class DurationParserTest : FunSpec({ + test("single duration parsing") { + DurationParser.parse("1ms").inWholeMilliseconds shouldBe 1 + DurationParser.parse("123sec").inWholeSeconds shouldBe 123 + DurationParser.parse("52 m").inWholeMinutes shouldBe 52 + DurationParser.parse("23hr").inWholeHours shouldBe 23 + DurationParser.parse("31d").inWholeDays shouldBe 31 + DurationParser.parse("12 w").inWholeDays / 7 shouldBe 12 + DurationParser.parse("5 months").inWholeDays / 30 shouldBe 5 + DurationParser.parse("2yr").inWholeDays / 365 shouldBe 2 + } + + test("multiple duration sum parsing") { + // Complex duration sum + DurationParser.parse("123sec 52 m 23hr 31d 12 w 5 months 2yr").inWholeSeconds shouldBe + 123 + 52 * 60 + 23 * 60 * 60 + 31 * 24 * 60 * 60 + + 12 * 7 * 24 * 60 * 60 + 5 * 30 * 24 * 60 * 60 + + 2 * 365 * 24 * 60 * 60 + + // Hour and minutes + DurationParser.parse("1 hr 20 mins").inWholeMinutes shouldBe 80 + + // Compact notation + DurationParser.parse("1h20m0s").inWholeMinutes shouldBe 80 + } + + test("duration parsing with special characters") { + DurationParser.parse("27,681 ms").inWholeMilliseconds shouldBe 27681 + DurationParser.parse("27_681 milliseconds").inWholeMilliseconds shouldBe 27681 + } + + test("empty duration string parsing") { + DurationParser.parse("").inWholeSeconds shouldBe 0 + } +}) \ No newline at end of file diff --git a/engine/engine-paper/src/test/kotlin/com/typewritermc/utils/PlaceholderParserTest.kt b/engine/engine-paper/src/test/kotlin/com/typewritermc/utils/PlaceholderParserTest.kt new file mode 100644 index 0000000000..bd7c8a3850 --- /dev/null +++ b/engine/engine-paper/src/test/kotlin/com/typewritermc/utils/PlaceholderParserTest.kt @@ -0,0 +1,204 @@ +package com.typewritermc.utils + +import com.typewritermc.engine.paper.entry.literal +import com.typewritermc.engine.paper.entry.placeholderParser +import com.typewritermc.engine.paper.entry.string +import com.typewritermc.engine.paper.entry.supply +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class PlaceholderParserTest : FunSpec({ + test("Parser should parse with no arguments") { + val parser = placeholderParser { + supply { + "Hey" + } + } + + parser.parse(null, emptyList()) shouldBe "Hey" + } + + test("Parser with no argument should fail when given an argument") { + val parser = placeholderParser { + supply { + "Hey" + } + } + + parser.parse(null, listOf("Hello")) shouldBe null + } + + context("Literals") { + test("Parser with single literal should parse when given the literal, otherwise it should fail") { + val parser = placeholderParser { + literal("hello") { + supply { + "Hey" + } + } + } + + parser.parse(null, listOf("hello")) shouldBe "Hey" + parser.parse(null, listOf("world")) shouldBe null + parser.parse(null, listOf("hello", "world")) shouldBe null + parser.parse(null, emptyList()) shouldBe null + } + test("Parser with multiple literals should only parse when all literals are supplied") { + val parser = placeholderParser { + literal("hello") { + supply { + "Hey" + } + } + literal("world") { + supply { + "World" + } + } + } + + parser.parse(null, listOf("hello")) shouldBe "Hey" + parser.parse(null, listOf("world")) shouldBe "World" + parser.parse(null, listOf("something")) shouldBe null + parser.parse(null, listOf("hello", "world")) shouldBe null + parser.parse(null, emptyList()) shouldBe null + } + test("Parser with multiple nested literals should only parse the correct literals") { + val parser = placeholderParser { + literal("hello") { + literal("world") { + supply { + "Hey" + } + } + } + } + + parser.parse(null, listOf("hello", "world")) shouldBe "Hey" + parser.parse(null, listOf("hello", "something")) shouldBe null + parser.parse(null, listOf("something", "world")) shouldBe null + parser.parse(null, emptyList()) shouldBe null + } + test("Parser with multiple executors should only supply the correct executor") { + val parser = placeholderParser { + literal("hello") { + supply { + "Hey" + } + } + supply { + "World" + } + } + + parser.parse(null, listOf("hello")) shouldBe "Hey" + parser.parse(null, listOf("world")) shouldBe null + parser.parse(null, emptyList()) shouldBe "World" + } + + test("Parser with overlapping literals will only parse where all arguments match") { + val parser = placeholderParser { + literal("hello") { + literal("world") { + supply { + "Hey" + } + } + supply { + "World" + } + } + literal("hello") { + literal("sun") { + supply { + "Sun" + } + } + supply { + "Impossible" + } + } + } + + parser.parse(null, listOf("hello", "world")) shouldBe "Hey" + parser.parse(null, listOf("hello", "sun")) shouldBe "Sun" + parser.parse(null, listOf("hello", "something")) shouldBe null + parser.parse(null, listOf("hello")) shouldBe "World" + parser.parse(null, emptyList()) shouldBe null + } + } + + context("String Argument") { + test("Parser with string argument should parse when given the argument, otherwise it should fail") { + val parser = placeholderParser { + string("name") { string -> + supply { + "Hey ${string()}" + } + } + } + + parser.parse(null, listOf("bob")) shouldBe "Hey bob" + parser.parse(null, listOf("alice")) shouldBe "Hey alice" + parser.parse(null, emptyList()) shouldBe null + } + + test("Parser with multiple nested string arguments should only parse when all arguments are supplied") { + val parser = placeholderParser { + string("name") { string -> + string("action") { action -> + supply { + "Hey ${string()}, ${action()}" + } + } + } + } + + parser.parse(null, listOf("bob", "jump")) shouldBe "Hey bob, jump" + parser.parse(null, listOf("bob", "run")) shouldBe "Hey bob, run" + parser.parse(null, listOf("alice", "jump")) shouldBe "Hey alice, jump" + parser.parse(null, listOf("alice", "run")) shouldBe "Hey alice, run" + parser.parse(null, listOf("bob")) shouldBe null + parser.parse(null, emptyList()) shouldBe null + } + + test("Parser with literal and string argument should only run the literal") { + val parser = placeholderParser { + literal("hello") { + supply { + "Hey" + } + } + + string("name") { string -> + supply { + "Hey ${string()}" + } + } + } + + parser.parse(null, listOf("hello")) shouldBe "Hey" + parser.parse(null, listOf("bob")) shouldBe "Hey bob" + parser.parse(null, emptyList()) shouldBe null + } + + test("Parser with where multiple paths are possible should only use the first") { + val parser = placeholderParser { + string("name") { string -> + supply { + "Hey ${string()}" + } + } + + string("name") { string -> + supply { + "Whats up ${string()}" + } + } + } + + parser.parse(null, listOf("bob")) shouldBe "Hey bob" + parser.parse(null, emptyList()) shouldBe null + } + } +}) \ No newline at end of file diff --git a/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/audience/CinematicSkippableAudienceEntry.kt b/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/audience/CinematicSkippableAudienceEntry.kt index 0c9f11b3b1..e78c9c15d6 100644 --- a/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/audience/CinematicSkippableAudienceEntry.kt +++ b/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/audience/CinematicSkippableAudienceEntry.kt @@ -6,12 +6,11 @@ import com.typewritermc.core.books.pages.Colors import com.typewritermc.core.entries.Ref import com.typewritermc.core.entries.ref import com.typewritermc.core.extension.annotations.Entry -import com.typewritermc.engine.paper.entry.PlaceholderEntry +import com.typewritermc.engine.paper.entry.* import com.typewritermc.engine.paper.entry.entries.AudienceEntry import com.typewritermc.engine.paper.entry.entries.AudienceFilter import com.typewritermc.engine.paper.entry.entries.AudienceFilterEntry import com.typewritermc.engine.paper.entry.entries.Invertible -import com.typewritermc.engine.paper.entry.findDisplay import com.typewritermc.engine.paper.events.AsyncCinematicEndEvent import org.bukkit.entity.Player import org.bukkit.event.EventHandler @@ -43,11 +42,13 @@ class CinematicSkippableAudienceEntry( return CinematicSkippableAudienceDisplay(ref()) } - override fun display(player: Player?): String? { - val default = SkipConfirmationKey.SNEAK.keybind - if (player == null) return default - val display = ref().findDisplay() as? CinematicSkippableAudienceDisplay ?: return default - return display.confirmationKey(player)?.keybind ?: default + override fun parser(): PlaceholderParser = placeholderParser { + supply { player -> + val default = SkipConfirmationKey.SNEAK.keybind + if (player == null) return@supply default + val display = ref().findDisplay() as? CinematicSkippableAudienceDisplay ?: return@supply default + display.confirmationKey(player)?.keybind ?: default + } } } diff --git a/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/fact/CountdownFact.kt b/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/fact/CountdownFact.kt index fad650747f..3b16b1f1ec 100644 --- a/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/fact/CountdownFact.kt +++ b/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/fact/CountdownFact.kt @@ -1,9 +1,11 @@ package com.typewritermc.basic.entries.fact import com.typewritermc.core.books.pages.Colors -import com.typewritermc.core.extension.annotations.Entry import com.typewritermc.core.entries.Ref import com.typewritermc.core.entries.emptyRef +import com.typewritermc.core.extension.annotations.Entry +import com.typewritermc.core.utils.formatCompact +import com.typewritermc.engine.paper.entry.* import com.typewritermc.engine.paper.entry.entries.ExpirableFactEntry import com.typewritermc.engine.paper.entry.entries.GroupEntry import com.typewritermc.engine.paper.entry.entries.PersistableFactEntry @@ -11,7 +13,9 @@ import com.typewritermc.engine.paper.facts.FactData import com.typewritermc.engine.paper.facts.FactId import java.time.Duration import java.time.LocalDateTime +import java.time.format.DateTimeFormatter import kotlin.math.max +import kotlin.time.Duration.Companion.seconds @Entry( "countdown_fact", @@ -52,4 +56,29 @@ class CountdownFact( val timeDifference = Duration.between(data.lastUpdate, LocalDateTime.now()) return max(0, (data.value - timeDifference.seconds).toInt()) } + + override fun parser(): PlaceholderParser = placeholderParser { + include(super.parser()) + + literal("time") { + literal("expires") { + literal("relative") { + supplyPlayer { player -> + val data = readForPlayersGroup(player) + val duration = data.value.seconds + + duration.formatCompact() + } + } + + supplyPlayer { player -> + val data = readForPlayersGroup(player) + val now = LocalDateTime.now() + val expireTime = now.plusSeconds(data.value.toLong()) + + expireTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + } + } + } + } } \ No newline at end of file diff --git a/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/fact/CronFactEntry.kt b/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/fact/CronFactEntry.kt index 5e48a57c3d..ecbce55102 100644 --- a/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/fact/CronFactEntry.kt +++ b/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/fact/CronFactEntry.kt @@ -1,17 +1,22 @@ package com.typewritermc.basic.entries.fact import com.typewritermc.core.books.pages.Colors -import com.typewritermc.core.extension.annotations.Entry -import com.typewritermc.core.extension.annotations.Help import com.typewritermc.core.entries.Ref import com.typewritermc.core.entries.emptyRef +import com.typewritermc.core.extension.annotations.Entry +import com.typewritermc.core.extension.annotations.Help +import com.typewritermc.core.utils.formatCompact +import com.typewritermc.engine.paper.entry.* import com.typewritermc.engine.paper.entry.entries.ExpirableFactEntry import com.typewritermc.engine.paper.entry.entries.GroupEntry import com.typewritermc.engine.paper.entry.entries.PersistableFactEntry import com.typewritermc.engine.paper.facts.FactData import com.typewritermc.engine.paper.facts.FactId import com.typewritermc.engine.paper.utils.CronExpression +import java.time.Duration import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.time.toKotlinDuration @Entry("cron_fact", "Saved until a specified date, like (0 0 * * 1)", Colors.PURPLE, "mingcute:calendar-time-add-fill") /** @@ -33,4 +38,34 @@ class CronFactEntry( override fun hasExpired(id: FactId, data: FactData): Boolean { return cron.nextLocalDateTimeAfter(data.lastUpdate).isBefore(LocalDateTime.now()) } + + override fun parser(): PlaceholderParser = placeholderParser { + include(super.parser()) + + literal("time") { + literal("expires") { + literal("relative") { + supplyPlayer { player -> + val lastUpdate = readForPlayersGroup(player).lastUpdate + val expires = cron.nextLocalDateTimeAfter(lastUpdate) + val now = LocalDateTime.now() + if (now.isAfter(expires)) { + return@supplyPlayer "now" + } + val difference = + Duration.between(lastUpdate, now).toKotlinDuration() + + difference.formatCompact() + } + } + + supplyPlayer { player -> + val lastUpdate = readForPlayersGroup(player).lastUpdate + val expires = cron.nextLocalDateTimeAfter(lastUpdate) + + expires.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + } + } + } + } } \ No newline at end of file diff --git a/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/fact/TimedFactEntry.kt b/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/fact/TimedFactEntry.kt index ea07c0e405..88aa69c87f 100644 --- a/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/fact/TimedFactEntry.kt +++ b/extensions/BasicExtension/src/main/kotlin/com/typewritermc/basic/entries/fact/TimedFactEntry.kt @@ -1,10 +1,12 @@ package com.typewritermc.basic.entries.fact import com.typewritermc.core.books.pages.Colors -import com.typewritermc.core.extension.annotations.Entry -import com.typewritermc.core.extension.annotations.Help import com.typewritermc.core.entries.Ref import com.typewritermc.core.entries.emptyRef +import com.typewritermc.core.extension.annotations.Entry +import com.typewritermc.core.extension.annotations.Help +import com.typewritermc.core.utils.formatCompact +import com.typewritermc.engine.paper.entry.* import com.typewritermc.engine.paper.entry.entries.ExpirableFactEntry import com.typewritermc.engine.paper.entry.entries.GroupEntry import com.typewritermc.engine.paper.entry.entries.PersistableFactEntry @@ -12,6 +14,10 @@ import com.typewritermc.engine.paper.facts.FactData import com.typewritermc.engine.paper.facts.FactId import java.time.Duration import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toKotlinDuration @Entry("timed_fact", "Saved for a specified duration, like 20 minutes", Colors.PURPLE, "bi:stopwatch-fill") /** @@ -33,4 +39,34 @@ class TimedFactEntry( override fun hasExpired(id: FactId, data: FactData): Boolean { return LocalDateTime.now().isAfter(data.lastUpdate.plus(duration)) } + + override fun parser(): PlaceholderParser = placeholderParser { + include(super.parser()) + + literal("time") { + literal("expires") { + literal("relative") { + supplyPlayer { player -> + val lastUpdate = readForPlayersGroup(player).lastUpdate + val expires = lastUpdate + duration + val now = LocalDateTime.now() + if (now.isAfter(expires)) { + return@supplyPlayer "now" + } + val difference = + Duration.between(lastUpdate, now).toKotlinDuration() + + difference.formatCompact() + } + } + + supplyPlayer { player -> + val lastUpdate = readForPlayersGroup(player).lastUpdate + val expires = lastUpdate + duration + + expires.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + } + } + } + } } diff --git a/extensions/_DocsExtension/src/main/kotlin/com/typewritermc/example/entries/ExamplePlaceholderEntry.kt b/extensions/_DocsExtension/src/main/kotlin/com/typewritermc/example/entries/ExamplePlaceholderEntry.kt new file mode 100644 index 0000000000..3ec93ff2cc --- /dev/null +++ b/extensions/_DocsExtension/src/main/kotlin/com/typewritermc/example/entries/ExamplePlaceholderEntry.kt @@ -0,0 +1,54 @@ +package com.typewritermc.example.entries + +import com.typewritermc.engine.paper.entry.* + +// +class SimpleExamplePlaceholderEntry( + override val id: String, + override val name: String, +) : PlaceholderEntry { + override fun parser(): PlaceholderParser = placeholderParser { + supply { player -> + "Hello, ${player?.name ?: "World"}!" + } + } +} +// + +class LiteralExamplePlaceholderEntry( + override val id: String, + override val name: String, +) : PlaceholderEntry { +// + override fun parser(): PlaceholderParser = placeholderParser { + literal("greet") { + literal("enthusiastic") { + supply { player -> + "HEY HOW IS YOUR DAY, ${player?.name ?: "World"}!" + } + } + supply { player -> + "Hello, ${player?.name ?: "World"}" + } + } + supply { + "Standard text" + } + } +// +} + +class StringExamplePlaceholderEntry( + override val id: String, + override val name: String, +) : PlaceholderEntry { +// + override fun parser(): PlaceholderParser = placeholderParser { + string("name") { name -> + supply { + "Hello, ${name()}!" + } + } + } +// +} \ No newline at end of file