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:
Claude
2026-05-13 22:10:30 +09:00
parent 3e59d08db1
commit 83f5180d57
12 changed files with 340 additions and 157 deletions

View File

@@ -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 쪽 같은 클래스와 충돌하지 않게 분리한다.
## 라이센스

View File

@@ -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 {
group = project.mod_group
version = project.mod_version
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 {
tasks.withType(JavaCompile).configureEach {
options.release = 21
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'
}

View File

@@ -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
View 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'
}

View File

@@ -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())
);
}
}

View File

@@ -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",

View File

@@ -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
View 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}" }
}
}

View File

@@ -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);
}
}
}

View 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"

View File

@@ -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'

View File

@@ -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();
}
}