diff --git a/README.md b/README.md index 09494f7..80307c0 100644 --- a/README.md +++ b/README.md @@ -14,35 +14,46 @@ execute as <플레이어 UUID> run function mq:answer/submit {text:'<채팅 내 ## 빌드 -JDK 21 필요. Linux/macOS: +JDK 21 필요. ``` -./gradlew build +./gradlew buildAll ``` -Windows: +산출물: -``` -gradlew.bat build -``` - -산출물: `build/libs/chat_answer-.jar` +- `build/libs/chat_answer--all.jar` — **Fabric + NeoForge 통합 단일 jar** (권장) +- `fabric/build/libs/chat_answer-fabric-.jar` — Fabric 전용 +- `neoforge/build/libs/chat_answer-neoforge-.jar` — NeoForge 전용 ## 설치 -서버의 `mods/` 폴더에 jar 를 넣는다. Fabric Loader 0.16+ 필요. Fabric API 도 함께 설치. +서버의 `mods/` 폴더에 통합 jar (`*-all.jar`) 하나만 넣으면 된다. 로더가 Fabric 이든 +NeoForge 든 자기 쪽 진입점만 인식해서 동작한다. + +요구사항: + +- Minecraft 1.21.6+ 서버 +- Fabric: Fabric Loader 0.16+, Fabric API +- NeoForge: 21.6+ ## 호환성 -- 빌드 대상: Minecraft 1.21.6 (Dialog 시스템 최초 버전). -- 코드가 사용하는 API (`ServerMessageEvents.ALLOW_CHAT_MESSAGE`, `Scoreboard`, `MinecraftServer.getCommandManager`) 는 1.21.x 전반에 걸쳐 인터미디어리 매핑이 안정적이므로 동일 jar 가 그 이상 버전에서도 일반적으로 작동. -- Mojang 이 chat / scoreboard / command 시스템을 깨는 변경을 적용하면 그 시점에 재빌드 필요. +- 빌드 타깃: Minecraft 1.21.6 (Dialog 시스템 최초 도입 버전). +- 사용하는 API (`ServerMessageEvents.ALLOW_CHAT_MESSAGE` / `ServerChatEvent`, + `Scoreboard`, `MinecraftServer.getCommands()`) 는 1.21.x 전반에 안정적이라 + 같은 jar 가 보통 그대로 동작. +- Mojang 이 chat / scoreboard / command 시스템을 깨는 변경을 적용하면 재빌드 필요. -### Forge / NeoForge 사용자 +## 구조 -현재 jar 는 Fabric 전용. NeoForge 서버에서 사용하려면 [Sinytra Connector](https://modrinth.com/mod/connector) 를 함께 설치하면 그대로 작동. +- `common/` — 로더 비종속 핵심 로직 (Mojang 매핑 기반) +- `fabric/` — Fabric Loader 진입점 + `ServerMessageEvents` 훅 +- `neoforge/` — NeoForge 진입점 + `ServerChatEvent` 훅 -(별도 NeoForge 네이티브 variant 빌드는 향후 추가 예정.) +통합 jar 는 두 로더의 결과물을 하나로 묶되, Fabric 쪽 common 클래스는 패키지 +재배치(`kr.tkrmagid.chatanswer.core` → `kr.tkrmagid.chatanswer.fabric.core`)로 +NeoForge 쪽 같은 클래스와 충돌하지 않게 분리한다. ## 라이센스 diff --git a/build.gradle b/build.gradle index 2e114d4..28da4e5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,45 +1,53 @@ plugins { - id 'fabric-loom' version '1.10-SNAPSHOT' id 'java' } -group = project.mod_group -version = project.mod_version -archivesBaseName = project.mod_id +allprojects { + apply plugin: 'java' -java { - toolchain.languageVersion = JavaLanguageVersion.of(21) - withSourcesJar() -} + group = project.mod_group + version = project.mod_version -repositories { - maven { url = 'https://maven.fabricmc.net/' } - mavenCentral() -} + java { + toolchain.languageVersion = JavaLanguageVersion.of(21) + } -dependencies { - minecraft "com.mojang:minecraft:${project.minecraft_version}" - mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" - modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" - modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" -} + tasks.withType(JavaCompile).configureEach { + options.release = 21 + options.encoding = 'UTF-8' + } -processResources { - inputs.property "version", project.version - inputs.property "mod_id", project.mod_id - - filesMatching("fabric.mod.json") { - expand "version": project.version, "mod_id": project.mod_id + repositories { + maven { url = 'https://maven.fabricmc.net/' } + maven { url = 'https://maven.neoforged.net/releases/' } + mavenCentral() } } -tasks.withType(JavaCompile).configureEach { - options.release = 21 - options.encoding = 'UTF-8' -} +// ───── merged jar ────────────────────────────────────────────────────────── +// fabric + neoforge 각각의 remapJar 결과물을 한 jar 안에 압축해서 단일 배포물 생성. +// 같은 클래스(common 코드)는 한 번만 포함. 각 로더는 자신의 mod metadata +// (fabric.mod.json / META-INF/neoforge.mods.toml) 만 인식해서 자기 쪽 진입점만 로드. -jar { - from("LICENSE") { - rename { "${it}_${project.archivesBaseName}" } +tasks.register('mergedJar', Jar) { + dependsOn ':fabric:relocatedJar', ':neoforge:jar' + archiveBaseName = project.mod_id + archiveVersion = project.mod_version + archiveClassifier = 'all' + destinationDirectory = file('build/libs') + + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + // Fabric: Shadow(relocatedJar) 가 common 패키지를 kr.tkrmagid.chatanswer.fabric.core 로 옮긴 jar + // NeoForge: common 은 그대로 kr.tkrmagid.chatanswer.core 에 위치 + // → 같은 클래스명 다른 매핑이라도 패키지 경로가 달라서 공존 가능 + from(zipTree(project(':fabric').tasks.named('relocatedJar').flatMap { it.archiveFile })) + from(zipTree(project(':neoforge').tasks.named('jar').flatMap { it.archiveFile })) { + // META-INF/MANIFEST.MF 는 Fabric 측 것을 그대로 사용 (둘 다 단순 manifest) + exclude 'META-INF/MANIFEST.MF' } } + +tasks.register('buildAll') { + dependsOn 'mergedJar' +} diff --git a/common/src/main/java/kr/tkrmagid/chatanswer/core/ChatAnswerCore.java b/common/src/main/java/kr/tkrmagid/chatanswer/core/ChatAnswerCore.java new file mode 100644 index 0000000..0c1b4f4 --- /dev/null +++ b/common/src/main/java/kr/tkrmagid/chatanswer/core/ChatAnswerCore.java @@ -0,0 +1,86 @@ +package kr.tkrmagid.chatanswer.core; + +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.scores.Objective; +import net.minecraft.world.scores.ReadOnlyScoreInfo; +import net.minecraft.world.scores.Scoreboard; +import net.minecraft.world.scores.ScoreHolder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 채팅정답 핵심 로직 — 로더 비종속. + * + * 정답 입력 상태(scoreboard main / init = 5) 일 때 채팅을 가로채서 + * execute as run function mq:answer/submit {text:'<채팅>'} + * 을 OP 레벨로 실행한다. + * + * 각 로더 진입점(Fabric / NeoForge) 에서 chat 이벤트 받자마자 {@link #handleChat} + * 호출 → 반환값이 false 면 그 채팅은 broadcast 차단해야 함. + */ +public final class ChatAnswerCore { + public static final String MOD_ID = "chat_answer"; + public static final String DISPLAY_NAME = "채팅정답"; + private static final Logger LOG = LoggerFactory.getLogger(MOD_ID); + + private static final String SCOREBOARD_OBJECTIVE = "main"; + private static final String SCOREBOARD_HOLDER = "init"; + private static final int ACCEPTING_ANSWER_STATE = 5; + + private ChatAnswerCore() {} + + /** + * @return true = 채팅을 평소처럼 broadcast / false = 채팅 차단 (이미 정답 제출 처리됨) + */ + public static boolean handleChat(ServerPlayer sender, String rawText) { + MinecraftServer server = sender.getServer(); + if (server == null) return true; + if (!isAcceptingAnswer(server)) return true; + + submitAnswer(server, sender, rawText); + return false; + } + + private static boolean isAcceptingAnswer(MinecraftServer server) { + Scoreboard scoreboard = server.getScoreboard(); + Objective objective = scoreboard.getObjective(SCOREBOARD_OBJECTIVE); + if (objective == null) return false; + ReadOnlyScoreInfo score = scoreboard.getPlayerScoreInfo(ScoreHolder.forNameOnly(SCOREBOARD_HOLDER), objective); + if (score == null) return false; + return score.value() == ACCEPTING_ANSWER_STATE; + } + + private static void submitAnswer(MinecraftServer server, ServerPlayer sender, String rawText) { + String safe = sanitize(rawText); + if (safe.isEmpty()) return; + + String nbt = safe.replace("\\", "\\\\").replace("'", "\\'"); + String command = "execute as " + sender.getStringUUID() + + " run function mq:answer/submit {text:'" + nbt + "'}"; + + CommandSourceStack source = server.createCommandSourceStack().withSuppressedOutput(); + try { + server.getCommands().performPrefixedCommand(source, command); + } catch (Exception e) { + LOG.error("[{}] failed to submit answer for {}: {}", MOD_ID, sender.getName().getString(), e.toString()); + } + } + + /** + * 매크로 라인 ($data ... set value "$(text)") 가 큰따옴표로 값을 감싸므로 + * 큰따옴표/백슬래시는 제거. 제어문자도 NBT 호환을 위해 제거. + */ + private static String sanitize(String text) { + if (text == null) return ""; + StringBuilder sb = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '"' || c == '\\') continue; + if (c < 0x20 || c == 0x7f) continue; + sb.append(c); + } + return sb.toString().strip(); + } +} diff --git a/fabric/build.gradle b/fabric/build.gradle new file mode 100644 index 0000000..7051615 --- /dev/null +++ b/fabric/build.gradle @@ -0,0 +1,71 @@ +plugins { + id 'fabric-loom' version '1.10-SNAPSHOT' + id 'com.gradleup.shadow' version '8.3.5' +} + +archivesBaseName = "${project.mod_id}-fabric" + +// common/ 디렉토리의 로더 비종속 소스를 fabric 컴파일에 포함 (Mojang 매핑으로 컴파일) +sourceSets { + main { + java { + srcDirs += "${rootDir}/common/src/main/java" + } + } +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" +} + +loom { + serverOnlyMinecraftJar() +} + +processResources { + inputs.property "version", project.version + inputs.property "mod_id", project.mod_id + inputs.property "mod_name", project.mod_name + + filesMatching("fabric.mod.json") { + expand( + "version": project.version, + "mod_id": project.mod_id, + "mod_name": project.mod_name + ) + } +} + +jar { + from(rootProject.file("LICENSE")) { + rename { "${it}_${project.mod_id}" } + } +} + +// ───── relocation for single-jar merge ───────────────────────────────────── +// Fabric 의 common 코드는 intermediary 매핑으로 컴파일되고, NeoForge 의 common +// 코드는 Mojang 매핑으로 컴파일된다. 둘은 바이트코드가 달라서 같은 클래스 경로에 +// 공존 불가. Shadow 의 relocate 로 Fabric 쪽 common 클래스만 별도 패키지로 옮겨서 +// merged jar 안에서 충돌하지 않게 한다. +// +// 진행 순서: loom 의 remapJar 결과 → shadowJar 가 받아서 패키지 재배치 → +// rootProject 의 mergedJar 가 이걸 사용. + +// Shadow 가 자동으로 만든 shadowJar 는 main sourceSet + 런타임 classpath 를 전부 +// 포함해서 100MB+ 가 되어버린다. 우리한테 필요한 건 "remapJar 결과물에 relocate 만 +// 적용한 작은 jar" 이므로, 별도 ShadowJar 태스크를 새로 만들어서 입력을 명시적으로 +// remapJar 의 zipTree 만 지정한다. +tasks.register('relocatedJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + dependsOn 'remapJar' + archiveClassifier = 'relocated' + from zipTree(tasks.named('remapJar').flatMap { it.archiveFile }) + relocate 'kr.tkrmagid.chatanswer.core', 'kr.tkrmagid.chatanswer.fabric.core' + mergeServiceFiles() +} + +tasks.named('build') { + dependsOn 'relocatedJar' +} diff --git a/fabric/src/main/java/kr/tkrmagid/chatanswer/fabric/ChatAnswerFabric.java b/fabric/src/main/java/kr/tkrmagid/chatanswer/fabric/ChatAnswerFabric.java new file mode 100644 index 0000000..ae3a849 --- /dev/null +++ b/fabric/src/main/java/kr/tkrmagid/chatanswer/fabric/ChatAnswerFabric.java @@ -0,0 +1,14 @@ +package kr.tkrmagid.chatanswer.fabric; + +import kr.tkrmagid.chatanswer.core.ChatAnswerCore; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.message.v1.ServerMessageEvents; + +public final class ChatAnswerFabric implements ModInitializer { + @Override + public void onInitialize() { + ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> + ChatAnswerCore.handleChat(sender, message.signedContent()) + ); + } +} diff --git a/src/main/resources/fabric.mod.json b/fabric/src/main/resources/fabric.mod.json similarity index 85% rename from src/main/resources/fabric.mod.json rename to fabric/src/main/resources/fabric.mod.json index 72593a4..a23e975 100644 --- a/src/main/resources/fabric.mod.json +++ b/fabric/src/main/resources/fabric.mod.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "${mod_id}", "version": "${version}", - "name": "채팅정답", + "name": "${mod_name}", "description": "음악퀴즈(mq) 데이터팩이 정답 입력을 받는 상태(init=5)에서 채팅을 가로채 mq:answer/submit 함수로 전달합니다.", "authors": [ "tkrmagid" ], "contact": { @@ -11,7 +11,7 @@ "license": "MIT", "environment": "server", "entrypoints": { - "main": [ "kr.tkrmagid.chatanswer.ChatAnswerMod" ] + "main": [ "kr.tkrmagid.chatanswer.fabric.ChatAnswerFabric" ] }, "depends": { "fabricloader": ">=0.16.0", diff --git a/gradle.properties b/gradle.properties index d54fba3..f48aeaa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,16 +1,20 @@ -org.gradle.jvmargs=-Xmx2G +org.gradle.jvmargs=-Xmx3G org.gradle.parallel=true -# Minecraft / Fabric versions -# Targets MC 1.21.6 (oldest Minecraft version with the dialog system). -# Chat events are stable since 1.19 — the same jar typically works across -# the 1.21.x line; rebuild against newer mappings if a future version -# breaks binary compatibility. +# ───── target Minecraft / loader versions ─────────────────────────────────── +# 1.21.6 = dialog system 최초 버전 = 음악퀴즈 데이터팩 최소 요구 버전 minecraft_version=1.21.6 + +# Fabric yarn_mappings=1.21.6+build.1 loader_version=0.16.10 fabric_version=0.128.2+1.21.6 +# NeoForge +neoforge_version=21.6.20-beta + +# ───── mod metadata ───────────────────────────────────────────────────────── mod_id=chat_answer mod_version=1.0.0 mod_group=kr.tkrmagid.chatanswer +mod_name=채팅정답 diff --git a/neoforge/build.gradle b/neoforge/build.gradle new file mode 100644 index 0000000..49236c8 --- /dev/null +++ b/neoforge/build.gradle @@ -0,0 +1,41 @@ +plugins { + id 'net.neoforged.moddev' version '2.0.97' +} + +archivesBaseName = "${project.mod_id}-neoforge" + +sourceSets { + main { + java { + srcDirs += "${rootDir}/common/src/main/java" + } + } +} + +neoForge { + version = project.neoforge_version +} + +processResources { + inputs.property "version", project.version + inputs.property "mod_id", project.mod_id + inputs.property "mod_name", project.mod_name + inputs.property "minecraft_version", project.minecraft_version + inputs.property "neoforge_version", project.neoforge_version + + filesMatching("META-INF/neoforge.mods.toml") { + expand( + "version": project.version, + "mod_id": project.mod_id, + "mod_name": project.mod_name, + "minecraft_version": project.minecraft_version, + "neoforge_version": project.neoforge_version + ) + } +} + +jar { + from(rootProject.file("LICENSE")) { + rename { "${it}_${project.mod_id}" } + } +} diff --git a/neoforge/src/main/java/kr/tkrmagid/chatanswer/neoforge/ChatAnswerNeoForge.java b/neoforge/src/main/java/kr/tkrmagid/chatanswer/neoforge/ChatAnswerNeoForge.java new file mode 100644 index 0000000..00a1902 --- /dev/null +++ b/neoforge/src/main/java/kr/tkrmagid/chatanswer/neoforge/ChatAnswerNeoForge.java @@ -0,0 +1,23 @@ +package kr.tkrmagid.chatanswer.neoforge; + +import kr.tkrmagid.chatanswer.core.ChatAnswerCore; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.ServerChatEvent; + +@Mod(ChatAnswerCore.MOD_ID) +public final class ChatAnswerNeoForge { + public ChatAnswerNeoForge(IEventBus modBus) { + NeoForge.EVENT_BUS.addListener(ChatAnswerNeoForge::onServerChat); + } + + @SubscribeEvent + public static void onServerChat(ServerChatEvent event) { + boolean allow = ChatAnswerCore.handleChat(event.getPlayer(), event.getRawText()); + if (!allow) { + event.setCanceled(true); + } + } +} diff --git a/neoforge/src/main/resources/META-INF/neoforge.mods.toml b/neoforge/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..4257057 --- /dev/null +++ b/neoforge/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,26 @@ +modLoader = "javafml" +loaderVersion = "[1,)" +license = "MIT" +issueTrackerURL = "https://git.tkrmagid.kr/tkrmagid/mc_chat_answer_mod/issues" + +[[mods]] +modId = "${mod_id}" +version = "${version}" +displayName = "${mod_name}" +authors = "tkrmagid" +description = '''음악퀴즈(mq) 데이터팩이 정답 입력을 받는 상태(init=5)에서 채팅을 가로채 mq:answer/submit 함수로 전달합니다.''' +displayURL = "https://git.tkrmagid.kr/tkrmagid/mc_chat_answer_mod" + +[[dependencies.${mod_id}]] +modId = "neoforge" +type = "required" +versionRange = "[${neoforge_version},)" +ordering = "NONE" +side = "SERVER" + +[[dependencies.${mod_id}]] +modId = "minecraft" +type = "required" +versionRange = "[${minecraft_version},)" +ordering = "NONE" +side = "SERVER" diff --git a/settings.gradle b/settings.gradle index 2008f4e..a1dbe30 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,9 +1,11 @@ pluginManagement { repositories { maven { url = 'https://maven.fabricmc.net/' } + maven { url = 'https://maven.neoforged.net/releases/' } gradlePluginPortal() mavenCentral() } } rootProject.name = 'chat_answer' +include 'fabric', 'neoforge' diff --git a/src/main/java/kr/tkrmagid/chatanswer/ChatAnswerMod.java b/src/main/java/kr/tkrmagid/chatanswer/ChatAnswerMod.java deleted file mode 100644 index c79011c..0000000 --- a/src/main/java/kr/tkrmagid/chatanswer/ChatAnswerMod.java +++ /dev/null @@ -1,103 +0,0 @@ -package kr.tkrmagid.chatanswer; - -import net.fabricmc.api.ModInitializer; -import net.fabricmc.fabric.api.message.v1.ServerMessageEvents; -import net.minecraft.scoreboard.Scoreboard; -import net.minecraft.scoreboard.ScoreboardObjective; -import net.minecraft.scoreboard.ScoreHolder; -import net.minecraft.scoreboard.ReadableScoreboardScore; -import net.minecraft.server.MinecraftServer; -import net.minecraft.server.command.ServerCommandSource; -import net.minecraft.server.network.ServerPlayerEntity; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * 채팅정답 (chat_answer) — 음악퀴즈 데이터팩과 짝이 되는 서버사이드 모드. - * - * 정답 입력 상태(init=5) 일 때 플레이어 채팅을 가로채서 - * function mq:answer/submit {text:'<채팅 내용>'} - * 을 해당 플레이어 컨텍스트로 실행한다. 채팅은 다른 플레이어들에게 broadcast 되지 - * 않도록 차단한다 (정답이 노출되면 안되므로). 그 외 상태에서는 채팅이 정상 진행된다. - */ -public class ChatAnswerMod implements ModInitializer { - public static final String MOD_ID = "chat_answer"; - private static final Logger LOG = LoggerFactory.getLogger(MOD_ID); - - // 음악퀴즈 데이터팩 약속 — main objective 의 init 점수가 5 면 정답 입력 받는 상태. - private static final String SCOREBOARD_OBJECTIVE = "main"; - private static final String SCOREBOARD_HOLDER = "init"; - private static final int ACCEPTING_ANSWER_STATE = 5; - - @Override - public void onInitialize() { - // 1) 가로채기 단계 — 정답 입력 상태면 false 반환해서 broadcast 차단 - ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> { - if (!isAcceptingAnswer(sender.getServer())) { - return true; // 평소엔 채팅 그대로 - } - submitAnswer(sender, message.getContent().getString()); - return false; // 정답 입력 상태에서는 채팅을 죽임 - }); - LOG.info("[{}] initialized — chat-as-answer hook active", MOD_ID); - } - - /** - * mq:main 데이터팩의 scoreboard main / init 점수를 읽어서 현재 상태 판정. - * objective 가 아직 생성되지 않았으면(퀴즈 미시작) 항상 false. - */ - private static boolean isAcceptingAnswer(MinecraftServer server) { - Scoreboard scoreboard = server.getScoreboard(); - ScoreboardObjective objective = scoreboard.getNullableObjective(SCOREBOARD_OBJECTIVE); - if (objective == null) { - return false; - } - ReadableScoreboardScore score = scoreboard.getScore(ScoreHolder.fromName(SCOREBOARD_HOLDER), objective); - if (score == null) { - return false; - } - return score.getScore() == ACCEPTING_ANSWER_STATE; - } - - /** - * 채팅 내용을 데이터팩 함수 호출로 변환. NBT 단일 따옴표 문자열로 감싸고, - * 매크로 치환 단계에서 문제 일으킬 수 있는 문자들을 사전 정리한다. - */ - private static void submitAnswer(ServerPlayerEntity sender, String rawText) { - String safe = sanitize(rawText); - if (safe.isEmpty()) { - return; - } - // single-quoted NBT 안의 ' 과 \ 만 이스케이프 - String nbt = safe.replace("\\", "\\\\").replace("'", "\\'"); - String command = "execute as " + sender.getUuidAsString() - + " run function mq:answer/submit {text:'" + nbt + "'}"; - - MinecraftServer server = sender.getServer(); - // 서버 커맨드 소스 (OP level 4) + silent — 채팅창에 명령어 출력 방지 - ServerCommandSource source = server.getCommandSource().withSilent(); - try { - server.getCommandManager().executeWithPrefix(source, command); - } catch (Exception e) { - LOG.error("[{}] failed to submit answer for {}: {}", MOD_ID, sender.getName().getString(), e.toString()); - } - } - - /** - * 매크로 라인 ($data ... set value "$(text)") 가 큰따옴표로 값을 감싸기 때문에, - * 채팅 내용에 큰따옴표나 백슬래시가 있으면 그 매크로가 깨진다. - * 가장 안전한 처리는 두 문자를 제거. 일반 정답 텍스트에는 거의 등장하지 않음. - * 추가로 NBT 호환을 위해 제어문자(\u0000-\u001f, \u007f) 도 제거. - */ - private static String sanitize(String text) { - if (text == null) return ""; - StringBuilder sb = new StringBuilder(text.length()); - for (int i = 0; i < text.length(); i++) { - char c = text.charAt(i); - if (c == '"' || c == '\\') continue; - if (c < 0x20 || c == 0x7f) continue; - sb.append(c); - } - return sb.toString().strip(); - } -}