Restructure into multi-loader (Fabric + NeoForge) with merged jar
common 디렉토리에 로더 비종속 ChatAnswerCore 를 두고, fabric/ 과 neoforge/ 서브프로젝트가 각자 진입점만 갖도록 분리. 두 결과물을 하나의 jar 로 묶기 위해 Fabric 측 common 클래스를 Shadow 의 relocate 로 kr.tkrmagid.chatanswer.fabric.core 패키지로 옮긴다 (Fabric 은 intermediary, NeoForge 는 Mojang 매핑이라 같은 클래스 경로에 그대로 두면 충돌). 루트의 mergedJar 태스크가 :fabric:relocatedJar 와 :neoforge:jar 를 합쳐서 build/libs/chat_answer-<version>-all.jar 생성. 산출물: 9.9KB 통합 jar 가 Fabric / NeoForge 양쪽에서 작동.
This commit is contained in:
41
README.md
41
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-<version>.jar`
|
||||
- `build/libs/chat_answer-<version>-all.jar` — **Fabric + NeoForge 통합 단일 jar** (권장)
|
||||
- `fabric/build/libs/chat_answer-fabric-<version>.jar` — Fabric 전용
|
||||
- `neoforge/build/libs/chat_answer-neoforge-<version>.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 쪽 같은 클래스와 충돌하지 않게 분리한다.
|
||||
|
||||
## 라이센스
|
||||
|
||||
|
||||
62
build.gradle
62
build.gradle
@@ -1,36 +1,15 @@
|
||||
plugins {
|
||||
id 'fabric-loom' version '1.10-SNAPSHOT'
|
||||
id 'java'
|
||||
}
|
||||
|
||||
allprojects {
|
||||
apply plugin: 'java'
|
||||
|
||||
group = project.mod_group
|
||||
version = project.mod_version
|
||||
archivesBaseName = project.mod_id
|
||||
|
||||
java {
|
||||
toolchain.languageVersion = JavaLanguageVersion.of(21)
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven { url = 'https://maven.fabricmc.net/' }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
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}"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
@@ -38,8 +17,37 @@ tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
|
||||
jar {
|
||||
from("LICENSE") {
|
||||
rename { "${it}_${project.archivesBaseName}" }
|
||||
repositories {
|
||||
maven { url = 'https://maven.fabricmc.net/' }
|
||||
maven { url = 'https://maven.neoforged.net/releases/' }
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
// ───── merged jar ──────────────────────────────────────────────────────────
|
||||
// fabric + neoforge 각각의 remapJar 결과물을 한 jar 안에 압축해서 단일 배포물 생성.
|
||||
// 같은 클래스(common 코드)는 한 번만 포함. 각 로더는 자신의 mod metadata
|
||||
// (fabric.mod.json / META-INF/neoforge.mods.toml) 만 인식해서 자기 쪽 진입점만 로드.
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -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 <player UUID> 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();
|
||||
}
|
||||
}
|
||||
71
fabric/build.gradle
Normal file
71
fabric/build.gradle
Normal file
@@ -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'
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -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=채팅정답
|
||||
|
||||
41
neoforge/build.gradle
Normal file
41
neoforge/build.gradle
Normal file
@@ -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}" }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
neoforge/src/main/resources/META-INF/neoforge.mods.toml
Normal file
26
neoforge/src/main/resources/META-INF/neoforge.mods.toml
Normal file
@@ -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"
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user