merge origin/main into owner branch for initial sync

This commit is contained in:
claude-owner
2026-05-20 10:50:45 +09:00
26 changed files with 1331 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Gradle
.gradle/
build/
out/
*.iml
.idea/
# Loom
.fabric/
run/
remappedSrc/
# OS
.DS_Store
Thumbs.db

205
HANDOVER.md Normal file
View File

@@ -0,0 +1,205 @@
# mc_chat_answer_mod 인수인계 (Handover)
이 문서는 본 모드 작업이 별도 채팅(Discord 룸)으로 분리되면서 새 채팅이
콜드 스타트로 인계받을 수 있도록 그동안의 컨텍스트를 정리한 것입니다.
모드를 계속 유지보수할 때 이 파일을 먼저 읽으면 됩니다.
## 한 줄 요약
음악퀴즈 데이터팩(`mc_datapack` / 데이터팩 namespace `mq`) 의 짝이 되는
멀티로더(Fabric + NeoForge) **서버사이드** Minecraft 모드. 두 가지 일을
한다:
1. **정답 채팅 가로채기**`init main == 5` (정답 입력 단계) 일 때 플레이어
채팅을 정답 제출로 OP 권한으로 처리.
2. **데이터팩 presence pulse**`mq_chat_mod` objective 의 `#server` 점수를
1 로 set 해서 데이터팩이 "이 모드 설치돼 있는가" 를 검사할 수 있게 함.
3. (부가) **모드 활성화 안내** — 로그인 1 초 뒤 `mq:players/mod_active_notice`
호출.
## 리포 / 접근
- URL: https://git.tkrmagid.kr/tkrmagid/mc_chat_answer_mod
- 기본 브랜치: `main`
- 라이센스: MIT
- 인증: Gitea PAT — 글로벌 메모리 `/home/claude/.config/ejclaw/secrets.json`
`credentials["git.tkrmagid.kr"].token`. HTTPS URL 에 임베드하거나
`Authorization: token <value>` 헤더로 사용.
- git author (커밋 시): `-c user.name="Claude (owner)" -c user.email="claude@tkrmagid.kr"`.
글로벌 git config 안 건드림.
## 최신 버전
**v1.3.8** (2026-05-20). 릴리스: https://git.tkrmagid.kr/tkrmagid/mc_chat_answer_mod/releases/tag/v1.3.8
## 폴더 구조
- `common/` — 로더 비종속 핵심 로직 (Mojang 매핑 기준).
- `kr/tkrmagid/chatanswer/core/ChatAnswerCore.java` — 거의 모든 로직이 여기.
- `fabric-1216/` — Fabric Loader MC 1.21.6 진입점.
- `fabric-2612/` — Fabric Loader MC 26.1.2 진입점.
- `neoforge-1216/` — NeoForge MC 1.21.6 진입점. (26.x 는 NeoForge moddev
plugin 이 아직 인식 못 함.)
- `containerJar` 태스크가 위 세 로더의 결과물을 **단일 jar** 로 묶음
(Fabric 은 META-INF/jars/ JiJ, NeoForge 는 outer 본체). 최종 산출:
`build/libs/chat_answer-<version>.jar`.
## 빌드
JDK 21 필요.
```
./gradlew buildAll
```
산출물: `build/libs/chat_answer-<version>.jar` (서버 mods/ 에 이거 하나만
넣으면 됨 — Fabric 이든 NeoForge 든 자기 진입점만 인식).
## 핵심 동작 상세
### A. handleChat (정답 채팅 처리)
`ChatAnswerCore.handleChat(ServerPlayer, String)`:
- 정답 단계(`init main == 5`) 가 아니면 그대로 통과 (true 반환).
- 정답 단계면 `sanitize` 후 OP 권한으로 다음 실행:
```
execute as <UUID> run function mq:answer/submit {text:'<채팅>'}
```
- **v1.3.7 까지**: 정답 단계에서 false 반환 → Fabric `ALLOW_CHAT_MESSAGE`
/ NeoForge `ServerChatEvent.setCanceled(true)` 가 broadcast 차단.
- **v1.3.8 부터**: 항상 true 반환 → broadcast 차단 안 함. 정답 채팅도
다른 플레이어에게 평소대로 보임. 정답 보호는 룸 운영자 신뢰 기반.
`sanitize` 는 큰따옴표/백슬래시/제어문자 제거 (매크로 NBT 호환).
### B. mod_active_notice (입장 안내)
`onPlayerJoin(ServerPlayer)` 가 UUID → 20 ticks 맵에 적재. 매 server tick
마다 카운트다운, 0 되면 player 본인을 source 로 한 CommandSourceStack 으로
`function mq:players/mod_active_notice` 호출.
**왜 지연하는가**: JOIN 이벤트는 플레이어가 PlayerList 에 막 들어간 직후라
클라이언트가 system chat 패킷 받을 준비가 안 됐을 수 있음. 즉시 tellraw 를
보내면 사라지는 race 가 v1.3.3 이하에서 재현됐음. 20 ticks (1 초) 지연으로
해결.
### C. presence pulse
`markModPresence(MinecraftServer)`:
```java
scoreboard.getOrCreatePlayerScore(
ScoreHolder.forNameOnly("#server"),
scoreboard.getObjective("mq_chat_mod")
).set(1);
```
- objective 가 없으면 (= 데이터팩 미설치) 조용히 skip.
- 점수가 이미 1 이면 MC 가 packet 전송 생략 → 매 tick 호출해도 트래픽
안 늘어남.
- **호출 지점 4 개** (어느 하나만 firing 돼도 가드 통과):
- `ServerLifecycleEvents.SERVER_STARTED` / `ServerStartedEvent` — 부팅 직후
- `onPlayerJoin` — 로그인 시
- `ServerTickEvents.END_SERVER_TICK` / `ServerTickEvent.Post` — steady-state
- `ServerLifecycleEvents.END_DATA_PACK_RELOAD` (success=true 시) /
`OnDatapackSyncEvent` — /reload 직후
**왜 중복**: banner/mohist 같은 fabric-bukkit 하이브리드 호스트에서
`END_SERVER_TICK` 이 안 들어오는 케이스, `/reload` 가 objective 를
remove/add 해서 `#server` 점수를 0 으로 reset 하는 케이스 등이 보고됨.
하나만 의존하면 false negative 가 남음.
## 데이터팩 (mc_datapack / mq) 측 통합
| 모드 → 데이터팩 | 모드가 호출하는 함수 / 쓰는 점수 |
|---|---|
| `mq:players/mod_active_notice` | 로그인 1 초 뒤 호출 |
| `mq:answer/submit {text:'...'}` | 정답 단계 채팅 시 호출 |
| `scoreboard mq_chat_mod` `#server` ← 1 | presence pulse |
| 데이터팩 → 모드 | 데이터팩이 읽는 점수 |
|---|---|
| `scoreboard main` `init` | 5 면 정답 단계 |
| `scoreboard mq_chat_mod` `#server` | 1 이면 모드 설치됨 (가드 통과) |
데이터팩의 `load.mcfunction` 이 `scoreboard objectives add mq_chat_mod dummy`
로 objective 를 생성하고 `#server` 를 0 으로 materialize. 모드는 그 objective
가 있어야 pulse 가 동작. 데이터팩의 `commands/start.mcfunction` 에서
`execute if score #server mq_chat_mod matches 1` 로 검증, 미설치 시
사유와 함께 차단.
데이터팩 최신: v1.0.26. README 에 `mc_chat_answer_mod` 최소 권장 버전
**v1.3.7+** 명시 (v1.3.8 은 상위 호환이라 README 갱신 불필요).
## 버전 히스토리 (요점만)
- **v1.1.0** — PlayerJoin 훅 추가, 데이터팩에 모드 설치 신호.
- **v1.1.1** — 싱글플레이어 (client side) 에서도 로드.
- **v1.2.0** — MC 26.1.2 타깃.
- **v1.2.1** — icon 추가.
- **v1.3.0** — 단일 jar 가 Fabric 1.21.6 + Fabric 26.1.2 + NeoForge 1.21.6 커버.
- **v1.3.1** — 중첩 fabric jar 를 outer fabric.mod.json 에 선언.
- **v1.3.2** — 진단 로깅.
- **v1.3.3** — storage flag 대신 직접 function 호출 (race 해소).
- **v1.3.4** — `mod_active_notice` 20 tick 지연 (chat-not-delivered race 해소).
**이 버전 미만이면 입장 안내 채팅이 가끔 사라짐.**
- **v1.3.5** — `#server mq_chat_mod` tick pulse 추가 (가드 도입).
- **v1.3.6** — fabric-bukkit 하이브리드에서 END_SERVER_TICK 안 들어오는
false negative → SERVER_STARTED + JOIN 에도 pulse.
- **v1.3.7** — /reload 가 objective remove/add 로 점수 reset 하는데
죽은 호스트 + 이미 접속 중 조합에서 SERVER_STARTED/JOIN/Tick 모두
안 발화 → END_DATA_PACK_RELOAD / OnDatapackSyncEvent 추가.
- **v1.3.8** — 정답 단계 broadcast 차단 해제 (사용자 요청).
## 알려진 이슈
v1.3.8 시점 기준 없음. 리뷰어 검증 통과 항목:
- `handleChat` 가 항상 true 반환 (jar 바이트코드 기준).
- 정답 단계에서는 `mq:answer/submit` 호출 후 broadcast 유지.
- 다른 채팅 cancel 경로 repo 전체에 없음.
- `./gradlew buildAll` 성공.
## 릴리스 절차
1. 코드 수정 + 테스트.
2. `gradle.properties` 의 `mod_version` 올림.
3. `./gradlew buildAll` 으로 jar 빌드 → `build/libs/chat_answer-<ver>.jar`.
4. 커밋 (위의 git author override 사용) + annotated tag `v<version>` + push.
5. Gitea 릴리스 생성:
```
POST https://git.tkrmagid.kr/api/v1/repos/tkrmagid/mc_chat_answer_mod/releases
Authorization: token <PAT>
Content-Type: application/json
{"tag_name":"v<ver>","name":"...","body":"..."}
```
응답의 `id` 받아서 자산 업로드:
```
POST .../releases/<id>/assets?name=chat_answer-<ver>.jar
-F "attachment=@build/libs/chat_answer-<ver>.jar"
```
6. 사용자에게 mods 폴더 jar 교체 안내.
호환성 깰 가능성이 있는 변경 (예: `mq:answer/submit` 호출 인자 변경,
`mq_chat_mod` objective 이름 변경 등) 은 반드시 mc_datapack 쪽 채팅과
조율해야 함.
## 작업 환경 메모
- 로컬 클론 경로 (참고): `/tmp/mc_chat_answer_mod`. owner Claude workspace
(`/home/claude/EJClaw/data/workspaces/mc_datapack/owner/`) 밖이므로
owner 브랜치 protocol 적용 대상 아님. mod repo 는 `main` 직접 push.
- 빌드 도구: Gradle wrapper (`./gradlew`), JDK 21.
- Loom 1.16.2, NeoForge moddev plugin 사용.
## 짝 데이터팩 (mc_datapack)
- URL: https://git.tkrmagid.kr/tkrmagid/mc_datapack
- 별도 채팅(현 mc_datapack 채팅) 에서 유지보수.
- 본 모드 작업이 데이터팩 호환을 깨면 그쪽 채팅에 알려야 함.
- 데이터팩 namespace `mq`, 메인 디렉토리 `music_quiz/data/mq/function/`.
---
이 문서는 mod repo 의 `HANDOVER.md` 로 커밋돼 있습니다. 새 채팅에서
`git clone` 후 이 파일부터 읽으면 됩니다.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 tkrmagid
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

61
README.md Normal file
View File

@@ -0,0 +1,61 @@
# chat_answer (채팅정답)
음악퀴즈(`mq`) 데이터팩의 짝이 되는 **서버사이드** 모드.
정답 입력을 받는 상태(`scoreboard players get init main == 5`) 동안 플레이어가
채팅을 입력하면 메시지를 가로채서 다음을 실행한다:
```
execute as <플레이어 UUID> run function mq:answer/submit {text:'<채팅 내용>'}
```
v1.3.8 부터는 정답 단계여도 채팅이 평소대로 broadcast 된다 (다른 플레이어
화면에 그대로 노출됨). 정답 보호는 데이터팩이 아니라 룸 운영자의 신뢰 기반
운영으로 처리한다. v1.3.7 까지는 정답 단계에서 채팅 broadcast 가 차단됐었다.
## 빌드
JDK 21 필요.
```
./gradlew buildAll
```
산출물:
- `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 (`*-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` / `ServerChatEvent`,
`Scoreboard`, `MinecraftServer.getCommands()`) 는 1.21.x 전반에 안정적이라
같은 jar 가 보통 그대로 동작.
- Mojang 이 chat / scoreboard / command 시스템을 깨는 변경을 적용하면 재빌드 필요.
## 구조
- `common/` — 로더 비종속 핵심 로직 (Mojang 매핑 기반)
- `fabric/` — Fabric Loader 진입점 + `ServerMessageEvents`
- `neoforge/` — NeoForge 진입점 + `ServerChatEvent`
통합 jar 는 두 로더의 결과물을 하나로 묶되, Fabric 쪽 common 클래스는 패키지
재배치(`kr.tkrmagid.chatanswer.core``kr.tkrmagid.chatanswer.fabric.core`)로
NeoForge 쪽 같은 클래스와 충돌하지 않게 분리한다.
## 라이센스
MIT

82
build.gradle Normal file
View File

@@ -0,0 +1,82 @@
plugins {
id 'java'
}
allprojects {
apply plugin: 'java'
group = project.mod_group
version = project.mod_version
// 기본 JDK toolchain 은 Java 25 (26.x Loom 빌드 요구). subproject 가 필요하면
// 자체 release 21 등으로 다운그레이드.
java {
toolchain.languageVersion = JavaLanguageVersion.of(25)
}
tasks.withType(JavaCompile).configureEach {
options.release = 25
options.encoding = 'UTF-8'
}
repositories {
maven { url = 'https://maven.fabricmc.net/' }
maven { url = 'https://maven.neoforged.net/releases/' }
mavenCentral()
}
}
// ───── 단일 배포 jar 컨테이너 ────────────────────────────────────────────────
// 한 jar 가 어떤 환경에서도 동작하도록:
// * outer = NeoForge 1.21.6 모드 본체 (NeoForge 만 fabric.mod.json 을 무시)
// + 메타로 fabric.mod.json (entrypoint 없는 컨테이너)
// * META-INF/jars/ = Fabric 용 nested jar 둘 (1.21.6 / 26.1.2)
// Fabric Loader 가 depends.minecraft 로 자동 매칭. NeoForge 는 무시.
//
// 결과: chat_answer-<version>.jar 한 개를 Fabric 1.21.6 / Fabric 26.1.2 / NeoForge
// 1.21.6 어디에 넣어도 적절한 코드 경로가 활성화된다.
tasks.register('containerJar', Jar) {
dependsOn ':fabric-1216:remapJar',
':fabric-2612:remapJar',
':neoforge-1216:jar'
archiveBaseName = project.mod_id
archiveVersion = project.mod_version
archiveClassifier = ''
destinationDirectory = file('build/libs')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
// 1. NeoForge 모드 본체 (classes + META-INF/neoforge.mods.toml + icon.png) 을 통째로.
// MANIFEST.MF 는 새 jar 가 자체적으로 생성하니 제외.
from(zipTree(project(':neoforge-1216').tasks.named('jar').flatMap { it.archiveFile })) {
exclude 'META-INF/MANIFEST.MF'
}
// 2. Fabric 컨테이너 메타데이터 (entrypoint 없이 그냥 "외피") 와 아이콘.
// fabric.mod.json 의 ${version} 만 치환.
filteringCharset = 'UTF-8'
from("${rootDir}/container-resources") {
filesMatching("fabric.mod.json") {
expand("version": project.mod_version)
}
}
// 3. Fabric nested jars. Fabric Loader 는 META-INF/jars/ 를 자동 스캔하지
// 않고 outer fabric.mod.json 의 "jars" 배열에 명시된 파일만 처리하므로,
// container-resources/fabric.mod.json 의 jars 항목과 일치하는 고정 파일명
// (버전 suffix 제거) 으로 넣는다.
into('META-INF/jars') {
from(project(':fabric-1216').tasks.named('remapJar').flatMap { it.archiveFile }) {
rename '.+\\.jar', 'chat_answer-fabric-1216.jar'
}
from(project(':fabric-2612').tasks.named('remapJar').flatMap { it.archiveFile }) {
rename '.+\\.jar', 'chat_answer-fabric-2612.jar'
}
}
}
tasks.register('buildAll') {
dependsOn 'containerJar'
}

View File

@@ -0,0 +1,196 @@
package kr.tkrmagid.chatanswer.core;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
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 레벨로 실행한다.
*
* v1.3.8 부터 채팅은 어떤 단계에서도 broadcast 차단하지 않는다 — 정답 입력
* 단계에서도 친 채팅이 평소처럼 채팅창에 보인다. (사용자 요청: 정답 화면
* 노출을 데이터팩이 관리하지 않고 룸 운영자가 신뢰 기반으로 처리.)
* 따라서 {@link #handleChat} 는 항상 true 를 반환하며, 정답 단계일 때만
* 부가적으로 정답 제출 함수를 호출한다.
*/
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;
/** 음악퀴즈 데이터팩이 선언한 "모드 존재 확인" 점수 이름.
* 본 모드는 서버 측에서 채팅을 가로채는 server-only 모드 — 클라이언트는
* 설치할 필요가 없고 server 한 곳에 있으면 모든 플레이어에게 적용된다.
* 따라서 per-player 검증은 무의미하고, fake player {@link #PRESENCE_HOLDER}
* 점수만 1 로 set 한다. 데이터팩의 start 가드는
* `score <PRESENCE_HOLDER> <OBJECTIVE> matches 1` 로 검사.
*
* presence pulse 는 여러 이벤트에서 중복 호출한다 — banner/mohist 같은
* fabric-bukkit 하이브리드 호스트에서 일부 Fabric 이벤트(특히
* ServerTickEvents.END_SERVER_TICK) 가 안 들어오는 케이스가 보고됨.
* SERVER_STARTED / PlayerJoin / TickEnd 셋 중 하나라도 firing 되면
* 데이터팩 가드가 통과하도록 모든 진입점에서 markModPresence 호출. */
private static final String MOD_PRESENCE_OBJECTIVE = "mq_chat_mod";
private static final String PRESENCE_HOLDER = "#server";
/** JOIN 이벤트 시점엔 클라이언트가 chat HUD 를 받을 준비가 안 됐을 수 있어
* tellraw 패킷이 사라지는 경우가 있다. 그래서 N 틱 늦춰서 호출한다. */
private static final int NOTICE_DELAY_TICKS = 20;
private static final Map<UUID, Integer> PENDING_NOTICES = new ConcurrentHashMap<>();
private ChatAnswerCore() {}
/**
* 플레이어 로그인 시점에 호출. 음악퀴즈 데이터팩의
* mq:players/mod_active_notice
* 함수를 해당 플레이어 컨텍스트로 호출한다. 단, JOIN 이벤트가 너무 일러서
* 즉시 호출 시 tellraw 가 클라이언트에 도달하지 못하는 race 가 있어
* {@link #NOTICE_DELAY_TICKS} 만큼 늦춘다 ({@link #onServerTick} 가 처리).
*/
public static void onPlayerJoin(ServerPlayer player) {
String name = player.getName().getString();
LOG.info("[{}] onPlayerJoin fired for {}, scheduling notice in {} ticks",
MOD_ID, name, NOTICE_DELAY_TICKS);
PENDING_NOTICES.put(player.getUUID(), NOTICE_DELAY_TICKS);
// tick 이벤트가 안 들어오는 호스트 대비 — join 시점에도 presence 한 번 찍는다.
MinecraftServer server = player.level().getServer();
if (server != null) markModPresence(server);
}
/** 각 로더 entrypoint 가 서버 부팅 완료 시점에 호출. tick 이벤트가
* 발화되지 않는 환경(banner/mohist) 에서 최소 한 번은 presence 가 찍히도록.
* 데이터팩 load 가 SERVER_STARTED 보다 먼저 끝나므로 objective 도 이미 존재. */
public static void onServerStarted(MinecraftServer server) {
LOG.info("[{}] onServerStarted fired, marking presence", MOD_ID);
markModPresence(server);
}
/** /reload 직후 호출. load.mcfunction 이 mq_chat_mod objective 를 remove/add
* 하고 `#server` 점수를 0 으로 재설정하므로, reload 끝난 직후 즉시
* 다시 1 로 찍어야 함. tick 이벤트가 죽은 호스트 + 이미 접속 중인
* 플레이어 조합에서 SERVER_STARTED/JOIN 둘 다 발화 안 되는 케이스 커버. */
public static void onDataPackReload(MinecraftServer server) {
LOG.info("[{}] onDataPackReload fired, re-marking presence", MOD_ID);
markModPresence(server);
}
/** 각 로더 entrypoint 가 매 server tick 마다 호출해야 한다. */
public static void onServerTick(MinecraftServer server) {
markModPresence(server);
if (PENDING_NOTICES.isEmpty()) return;
Iterator<Map.Entry<UUID, Integer>> it = PENDING_NOTICES.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<UUID, Integer> e = it.next();
int remaining = e.getValue() - 1;
if (remaining > 0) {
e.setValue(remaining);
continue;
}
UUID uuid = e.getKey();
it.remove();
ServerPlayer player = server.getPlayerList().getPlayer(uuid);
if (player == null) continue;
deliverNotice(server, player);
}
}
/**
* 데이터팩의 mq_chat_mod 점수(fake player #server 키) 를 1 로 set.
* 데이터팩이 아직 load 되지 않아 objective 가 없으면 조용히 skip.
* 점수 값이 이미 1 이면 Minecraft 가 packet 전송을 생략하므로
* 매 tick 호출해도 트래픽은 늘지 않는다.
*/
private static void markModPresence(MinecraftServer server) {
Scoreboard scoreboard = server.getScoreboard();
Objective objective = scoreboard.getObjective(MOD_PRESENCE_OBJECTIVE);
if (objective == null) return;
scoreboard.getOrCreatePlayerScore(ScoreHolder.forNameOnly(PRESENCE_HOLDER), objective).set(1);
}
private static void deliverNotice(MinecraftServer server, ServerPlayer player) {
String name = player.getName().getString();
// 플레이어 자체를 source 로 써서 함수 안의 @s 가 그대로 player.
CommandSourceStack source = player.createCommandSourceStack().withSuppressedOutput();
try {
server.getCommands().performPrefixedCommand(source, "function mq:players/mod_active_notice");
LOG.info("[{}] mod_active_notice delivered for {}", MOD_ID, name);
} catch (Exception e) {
LOG.warn("[{}] failed to deliver mod_active_notice for {}: {}", MOD_ID, name, e.toString(), e);
}
}
/**
* 항상 true 반환 — 어떤 단계에서도 채팅을 차단하지 않는다.
* 정답 단계(state 5) 일 때만 부가적으로 정답 제출 함수를 호출한다.
*
* @return 항상 true (broadcast 허용). 로더 진입점은 반환값을 그대로 이벤트
* allow/cancel 결정에 전달하면 된다.
*/
public static boolean handleChat(ServerPlayer sender, String rawText) {
MinecraftServer server = sender.level().getServer();
if (server == null) return true;
if (isAcceptingAnswer(server)) {
submitAnswer(server, sender, rawText);
}
return true;
}
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();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"id": "chat_answer",
"version": "${version}",
"name": "채팅정답",
"description": "음악퀴즈(mq) 데이터팩이 정답 입력을 받는 상태(init=5)에서 채팅을 가로채 mq:answer/submit 함수로 전달합니다. 단일 jar 에 1.21.6 (Fabric/NeoForge) + 26.1.2 (Fabric) 빌드가 모두 들어있어 어느 환경에서도 그대로 동작합니다.",
"authors": [ "tkrmagid" ],
"license": "MIT",
"icon": "assets/chat_answer/icon.png",
"environment": "*",
"jars": [
{ "file": "META-INF/jars/chat_answer-fabric-1216.jar" },
{ "file": "META-INF/jars/chat_answer-fabric-2612.jar" }
],
"depends": {
"fabricloader": ">=0.16.0",
"java": ">=21"
}
}

53
fabric-1216/build.gradle Normal file
View File

@@ -0,0 +1,53 @@
plugins {
id 'fabric-loom' version '1.16-SNAPSHOT'
}
base.archivesName = "${project.mod_id}-fabric-1216"
// 1.21.6 은 Java 21 런타임. release 21 로 컴파일.
java {
toolchain.languageVersion = JavaLanguageVersion.of(25)
}
tasks.withType(JavaCompile).configureEach {
options.release = 21
}
// common/ 디렉토리의 로더 비종속 소스 포함. Mojang 매핑으로 컴파일됨.
sourceSets {
main {
java {
srcDirs += "${rootDir}/common/src/main/java"
}
}
}
dependencies {
minecraft "com.mojang:minecraft:${project.mc_1216}"
mappings loom.officialMojangMappings()
modImplementation "net.fabricmc:fabric-loader:${project.fabric_loader_1216}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_1216}"
}
loom {
serverOnlyMinecraftJar()
}
processResources {
inputs.property "version", project.version
inputs.property "mod_id", project.mod_id
filteringCharset = 'UTF-8'
filesMatching("fabric.mod.json") {
expand(
"version": project.version,
"mod_id": project.mod_id
)
}
}
jar {
from(rootProject.file("LICENSE")) {
rename { "${it}_${project.mod_id}_fabric_1216" }
}
}

View File

@@ -0,0 +1,36 @@
package kr.tkrmagid.chatanswer.fabric;
import kr.tkrmagid.chatanswer.core.ChatAnswerCore;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.message.v1.ServerMessageEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class ChatAnswerFabric implements ModInitializer {
private static final Logger LOG = LoggerFactory.getLogger(ChatAnswerCore.MOD_ID);
@Override
public void onInitialize() {
LOG.info("[{}] Fabric entrypoint onInitialize starting", ChatAnswerCore.MOD_ID);
try {
ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) ->
ChatAnswerCore.handleChat(sender, message.signedContent())
);
ServerLifecycleEvents.SERVER_STARTED.register(ChatAnswerCore::onServerStarted);
ServerLifecycleEvents.END_DATA_PACK_RELOAD.register((server, resourceManager, success) -> {
if (success) ChatAnswerCore.onDataPackReload(server);
});
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) ->
ChatAnswerCore.onPlayerJoin(handler.player)
);
ServerTickEvents.END_SERVER_TICK.register(ChatAnswerCore::onServerTick);
LOG.info("[{}] Fabric entrypoint registered: ALLOW_CHAT_MESSAGE + SERVER_STARTED + END_DATA_PACK_RELOAD + JOIN + TICK", ChatAnswerCore.MOD_ID);
} catch (Throwable t) {
LOG.error("[{}] Fabric entrypoint event registration failed", ChatAnswerCore.MOD_ID, t);
throw t;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "chat_answer_fabric",
"version": "${version}",
"name": "채팅정답 (Fabric impl)",
"description": "음악퀴즈(mq) 데이터팩이 정답 입력을 받는 상태(init=5)에서 채팅을 가로채 mq:answer/submit 함수로 전달합니다. (MC 1.21.6 변형)",
"authors": [ "tkrmagid" ],
"license": "MIT",
"icon": "assets/chat_answer/icon.png",
"environment": "*",
"entrypoints": {
"main": [ "kr.tkrmagid.chatanswer.fabric.ChatAnswerFabric" ]
},
"depends": {
"fabricloader": ">=0.16.0",
"minecraft": ">=1.21.6 <1.22",
"java": ">=21",
"fabric-api": "*"
}
}

42
fabric-2612/build.gradle Normal file
View File

@@ -0,0 +1,42 @@
plugins {
id 'fabric-loom' version '1.16-SNAPSHOT'
}
base.archivesName = "${project.mod_id}-fabric-2612"
// common/ 디렉토리의 로더 비종속 소스 포함.
sourceSets {
main {
java {
srcDirs += "${rootDir}/common/src/main/java"
}
}
}
dependencies {
// MC 26.x: server jar 가 unobfuscated. intermediary 0.0.0 = identity mapping.
minecraft "com.mojang:minecraft:${project.mc_2612}"
mappings "net.fabricmc:intermediary:0.0.0:v2"
implementation "net.fabricmc:fabric-loader:${project.fabric_loader_2612}"
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_2612}"
}
processResources {
inputs.property "version", project.version
inputs.property "mod_id", project.mod_id
filteringCharset = 'UTF-8'
filesMatching("fabric.mod.json") {
expand(
"version": project.version,
"mod_id": project.mod_id
)
}
}
jar {
from(rootProject.file("LICENSE")) {
rename { "${it}_${project.mod_id}_fabric_2612" }
}
}

View File

@@ -0,0 +1,36 @@
package kr.tkrmagid.chatanswer.fabric;
import kr.tkrmagid.chatanswer.core.ChatAnswerCore;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.message.v1.ServerMessageEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class ChatAnswerFabric implements ModInitializer {
private static final Logger LOG = LoggerFactory.getLogger(ChatAnswerCore.MOD_ID);
@Override
public void onInitialize() {
LOG.info("[{}] Fabric entrypoint onInitialize starting", ChatAnswerCore.MOD_ID);
try {
ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) ->
ChatAnswerCore.handleChat(sender, message.signedContent())
);
ServerLifecycleEvents.SERVER_STARTED.register(ChatAnswerCore::onServerStarted);
ServerLifecycleEvents.END_DATA_PACK_RELOAD.register((server, resourceManager, success) -> {
if (success) ChatAnswerCore.onDataPackReload(server);
});
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) ->
ChatAnswerCore.onPlayerJoin(handler.player)
);
ServerTickEvents.END_SERVER_TICK.register(ChatAnswerCore::onServerTick);
LOG.info("[{}] Fabric entrypoint registered: ALLOW_CHAT_MESSAGE + SERVER_STARTED + END_DATA_PACK_RELOAD + JOIN + TICK", ChatAnswerCore.MOD_ID);
} catch (Throwable t) {
LOG.error("[{}] Fabric entrypoint event registration failed", ChatAnswerCore.MOD_ID, t);
throw t;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "chat_answer_fabric",
"version": "${version}",
"name": "채팅정답 (Fabric impl)",
"description": "음악퀴즈(mq) 데이터팩이 정답 입력을 받는 상태(init=5)에서 채팅을 가로채 mq:answer/submit 함수로 전달합니다. (MC 26.1.2 변형)",
"authors": [ "tkrmagid" ],
"license": "MIT",
"icon": "assets/chat_answer/icon.png",
"environment": "*",
"entrypoints": {
"main": [ "kr.tkrmagid.chatanswer.fabric.ChatAnswerFabric" ]
},
"depends": {
"fabricloader": ">=0.19.0",
"minecraft": ">=26.1.2",
"java": ">=21",
"fabric-api": "*"
}
}

26
gradle.properties Normal file
View File

@@ -0,0 +1,26 @@
org.gradle.jvmargs=-Xmx3G
org.gradle.parallel=true
# ───── mod metadata ─────────────────────────────────────────────────────────
mod_id=chat_answer
mod_version=1.3.8
mod_group=kr.tkrmagid.chatanswer
mod_name=채팅정답
# ───── per-target MC / loader versions ──────────────────────────────────────
# 한 jar 로 1.21.6 (Fabric/NeoForge) + 26.1.2 (Fabric) 전부 커버하기 위해
# 각 타겟마다 별도 subproject 가 자기 버전으로 빌드되고, 결과물을 outer
# container jar 가 묶는다 (Fabric 은 META-INF/jars/ JiJ, NeoForge 는 outer 본체).
# Fabric MC 1.21.6
mc_1216=1.21.6
fabric_api_1216=0.128.2+1.21.6
fabric_loader_1216=0.16.10
# Fabric MC 26.1.2 (26.x 서버 jar 는 unobfuscated. intermediary 0.0.0 = identity)
mc_2612=26.1.2
fabric_api_2612=0.148.2+26.1.2
fabric_loader_2612=0.19.2
# NeoForge MC 1.21.6 (26.x 는 NeoForge moddev plugin 이 아직 인식 못 함)
neoforge_1216=21.6.20-beta

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,49 @@
plugins {
id 'net.neoforged.moddev' version '2.0.97'
}
base.archivesName = "${project.mod_id}-neoforge-1216"
// NeoForge 1.21.6 은 Java 21. release 21 로 컴파일.
java {
toolchain.languageVersion = JavaLanguageVersion.of(25)
}
tasks.withType(JavaCompile).configureEach {
options.release = 21
}
sourceSets {
main {
java {
srcDirs += "${rootDir}/common/src/main/java"
}
}
}
neoForge {
version = project.neoforge_1216
}
processResources {
inputs.property "version", project.version
inputs.property "mod_id", project.mod_id
inputs.property "minecraft_version", project.mc_1216
inputs.property "neoforge_version", project.neoforge_1216
filteringCharset = 'UTF-8'
filesMatching("META-INF/neoforge.mods.toml") {
expand(
"version": project.version,
"mod_id": project.mod_id,
"minecraft_version": project.mc_1216,
"neoforge_version": project.neoforge_1216
)
}
}
jar {
from(rootProject.file("LICENSE")) {
rename { "${it}_${project.mod_id}_neoforge_1216" }
}
}

View File

@@ -0,0 +1,57 @@
package kr.tkrmagid.chatanswer.neoforge;
import kr.tkrmagid.chatanswer.core.ChatAnswerCore;
import net.minecraft.server.level.ServerPlayer;
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.OnDatapackSyncEvent;
import net.neoforged.neoforge.event.ServerChatEvent;
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.event.server.ServerStartedEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
@Mod(ChatAnswerCore.MOD_ID)
public final class ChatAnswerNeoForge {
public ChatAnswerNeoForge(IEventBus modBus) {
NeoForge.EVENT_BUS.addListener(ChatAnswerNeoForge::onServerChat);
NeoForge.EVENT_BUS.addListener(ChatAnswerNeoForge::onServerStarted);
NeoForge.EVENT_BUS.addListener(ChatAnswerNeoForge::onDatapackSync);
NeoForge.EVENT_BUS.addListener(ChatAnswerNeoForge::onPlayerLogin);
NeoForge.EVENT_BUS.addListener(ChatAnswerNeoForge::onServerTick);
}
@SubscribeEvent
public static void onServerChat(ServerChatEvent event) {
boolean allow = ChatAnswerCore.handleChat(event.getPlayer(), event.getRawText());
if (!allow) {
event.setCanceled(true);
}
}
@SubscribeEvent
public static void onServerStarted(ServerStartedEvent event) {
ChatAnswerCore.onServerStarted(event.getServer());
}
/** OnDatapackSyncEvent: /reload 끝나면 player=null 로 한 번 broadcast,
* 로그인 때마다 해당 player 로 한 번 더 fire. 어느 쪽이든 reload 직후
* presence 가 다시 찍히는 것이 목적이라 둘 다 OK. */
@SubscribeEvent
public static void onDatapackSync(OnDatapackSyncEvent event) {
ChatAnswerCore.onDataPackReload(event.getPlayerList().getServer());
}
@SubscribeEvent
public static void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
ChatAnswerCore.onPlayerJoin(player);
}
}
@SubscribeEvent
public static void onServerTick(ServerTickEvent.Post event) {
ChatAnswerCore.onServerTick(event.getServer());
}
}

View File

@@ -0,0 +1,25 @@
modLoader = "javafml"
loaderVersion = "[1,)"
license = "MIT"
[[mods]]
modId = "${mod_id}"
version = "${version}"
displayName = "채팅정답"
authors = "tkrmagid"
description = '''음악퀴즈(mq) 데이터팩이 정답 입력을 받는 상태(init=5)에서 채팅을 가로채 mq:answer/submit 함수로 전달합니다.'''
logoFile = "icon.png"
[[dependencies.${mod_id}]]
modId = "neoforge"
type = "required"
versionRange = "[${neoforge_version},)"
ordering = "NONE"
side = "BOTH"
[[dependencies.${mod_id}]]
modId = "minecraft"
type = "required"
versionRange = "[${minecraft_version},)"
ordering = "NONE"
side = "BOTH"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

16
settings.gradle Normal file
View File

@@ -0,0 +1,16 @@
pluginManagement {
repositories {
maven { url = 'https://maven.fabricmc.net/' }
maven { url = 'https://maven.neoforged.net/releases/' }
gradlePluginPortal()
mavenCentral()
}
}
rootProject.name = 'chat_answer'
// 세 개의 target-specific subproject. 각각 자기 MC/로더 버전으로 컴파일/리맵.
// rootProject 의 containerJar 가 셋의 산출물을 하나로 묶어 단일 jar 배포물 생성.
include 'fabric-1216'
include 'fabric-2612'
include 'neoforge-1216'