Compare commits

20 Commits

Author SHA1 Message Date
b918b79fd5 ChatScreenMixin: switch to KeyEvent param (MC 1.21.9+ input refactor)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-06-24 13:30:19 +06:00
db20575681 Bump mod_version to 1.0.3
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-06-22 12:11:35 +06:00
1bbc10c9a9 Ignore .claude/ working files
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-22 12:07:38 +06:00
b6ce51dfb9 WorldOverlayRenderer: fix RenderType path and Camera.position() type
All checks were successful
continuous-integration/drone/push Build is passing
CI on 3b5e555 revealed:
- net.minecraft.client.renderer.RenderType moved to
  net.minecraft.client.renderer.rendertype; use RenderTypes.lines() factory.
- Camera.position() returns Vec3, not Vector3fc as I guessed.

OrderedSubmitNodeCollector, submitShapeOutline, Shapes.block/box,
LevelRenderEvents.AFTER_TRANSLUCENT_FEATURES — all compiled cleanly.
2026-06-22 12:06:54 +06:00
3b5e555ce4 Re-add WorldOverlayRenderer using OrderedSubmitNodeCollector.submitShapeOutline
Some checks failed
continuous-integration/drone/push Build is failing
MC 26.2 removed MultiBufferSource; new in-world outline drawing goes through
OrderedSubmitNodeCollector#submitShapeOutline (PoseStack, VoxelShape,
RenderType, color, width, beforeTranslucent).

- LevelRenderEvents.AFTER_TRANSLUCENT_FEATURES is the new entry point (was
  WorldRenderEvents.AFTER_ENTITIES).
- Friend dots: small 0.3-unit voxel cube above player head, green.
- Markers: 1x1x1 cube outline + thin tall beam (0.1x50x0.1), magenta.
- Camera.position() returns Vector3fc; camera offset applied to render in
  camera-relative space.
- Casts SubmitNodeCollector to OrderedSubmitNodeCollector; if vanilla wraps
  it differently we'll see in the next CI cycle.
2026-06-22 12:01:10 +06:00
74bcd64bc0 Re-add OverlayHud against MC 26.2 HUD API
All checks were successful
continuous-integration/drone/push Build is passing
- HudElementRegistry.attachElementAfter(VanillaHudElements.MISC_OVERLAYS, ...)
  registers compass + watermark as HudElement implementations.
- HudElement#extractRenderState(GuiGraphicsExtractor, DeltaTracker) is the
  callback signature; vanilla auto-skips when Hud.isHidden().
- GuiGraphicsExtractor renames: drawString -> text. fill is unchanged.
- Camera became record-like: getMainCamera() -> mainCamera(), getYRot() -> yRot().
- Identifier replaces ResourceLocation (since 26.1).
2026-06-22 11:57:01 +06:00
6f75e29ac6 Fix MC 26.2 / Fabric API 0.152.2 build: strip world+HUD overlays, use new keymapping API
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
MC 26.2 overhauled rendering significantly. The classes we used don't
exist anymore:

- MultiBufferSource was removed; world line drawing now goes through
  SubmitNodeCollector via LevelRenderEvents.COLLECT_SUBMITS — a much
  larger rewrite. Defer until next iteration.
- HudLayerRegistrationCallback / IdentifiedLayer / LayeredDraw — replaced
  by HudElementRegistry / HudElement / VanillaHudElements. Compass and
  watermark deferred until next iteration.
- fabric-key-binding-api-v1 module renamed to fabric-key-mapping-api-v1.
  Class is now KeyMappingHelper at net.fabricmc.fabric.api.client.keymapping.v1.
- KeyMapping ctor: last arg is KeyMapping.Category, not a String.
- BlockPos#getCenter removed — construct Vec3 manually.
- ResourceKey#location renamed to identifier (returns Identifier).
- ResourceLocation was renamed to Identifier (in 26.1).

Friends/markers/chat/tab-complete still work; marker_set from friends
shows up as a chat line "[IRC] marker from X @ x, y, z" instead of an
in-world beacon for this iteration.
2026-06-22 11:40:55 +06:00
be277c08c1 Initial commit
Some checks failed
continuous-integration/drone/push Build is failing
2026-06-22 11:13:10 +06:00
fb9218459b Added token and host in config 2026-06-21 21:35:17 +06:00
c25e394d50 Add friends, markers, HUD overlays, tab complete
- FriendsManager persists a per-user friend list at config/clientirc-friends.json.
- MarkerManager holds one ephemeral marker per owner (5 min expiry).
- IRC protocol extended with marker_set / marker_remove; server relays them.
- IrcService DisplayLine replaced with sealed IrcEvent (Chat / System / MarkerSet / MarkerRemove); chat formatting moved into the client.
- ClientIrcMod handles .friends add/remove/list, .marker remove, .help; chat lines from friends are prefixed with a green dot; marker events from non-friends are ignored.
- OverlayHud draws a top-center compass strip and a top-left watermark (nick / FPS / ping) via HudLayerRegistrationCallback.
- WorldOverlayRenderer draws green crosses above friends and magenta beams + base cubes at active markers via WorldRenderEvents.AFTER_ENTITIES.
- MiddleClickHandler binds MMB via KeyMapping, raycasts a block or 20-block look ray, stores locally and sends marker_set on a daemon executor.
- ChatScreenMixin completes . prefix commands on Tab; first mixin in the project, plumbed through clientirc.client.mixins.json and fabric.mod.json.
2026-06-21 21:16:32 +06:00
0112bde962 Publish jars as a Gitea release on tag push
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
Add a gitea-release step to the Drone pipeline that uploads everything
in build/libs/*.jar to a Gitea Release named after the pushed tag,
authenticating with a gitea_token secret. The step is guarded with
when.event tag, and tag was added to the pipeline trigger so the build
itself runs for tag events. Document the one-time token / secret setup
and the tag workflow in the README.
2026-06-21 16:18:23 +06:00
b3f24d9828 Switch to Entity.sendSystemMessage for chat output
All checks were successful
continuous-integration/drone/push Build is passing
displayClientMessage(Component, boolean) was removed from LocalPlayer
in 26.2. sendSystemMessage(Component) is inherited from Entity, has
been part of Mojang's public API since 1.19, and LocalPlayer overrides
it to route the message into the local chat hud, which is exactly the
behaviour we want.
2026-06-21 16:10:40 +06:00
0e6377fce1 Route chat output through LocalPlayer.displayClientMessage
Some checks failed
continuous-integration/drone/push Build is failing
Gui.chat is private in 26.2 and Gui.getChat() is gone, so the previous
direct-field access does not compile. Player.displayClientMessage is a
public, stable API for client-side chat output, so push everything
through it. When the player is null (e.g. on the title screen before
join, where the auto-connect "Connected to ..." line lands), fall back
to the logger so the message is not silently dropped.
2026-06-21 16:07:19 +06:00
b0501acfcd Update chat and GameProfile accessors for 26.2
Some checks failed
continuous-integration/drone/push Build is failing
In 26.2 Mojang dropped the getChat() wrapper on Gui in favour of the
public 'chat' field, and com.mojang.authlib.GameProfile is now a record
so its getter is name() rather than getName(). Reflect both changes in
the IRC chat dispatcher.
2026-06-21 16:03:52 +06:00
5f366792c3 Use plain implementation for Fabric deps under unobfuscated Loom
Some checks failed
continuous-integration/drone/push Build is failing
Loom 1.17 in unobfuscated mode does not register the modImplementation
configuration: there is no remapping step that would need it. Use plain
implementation for fabric-loader and fabric-api, matching the original
work in 19b6ea5 that I had rolled back.
2026-06-21 15:59:08 +06:00
8a969bc843 Drop mappings declaration for unobfuscated 26.2
Some checks failed
continuous-integration/drone/push Build is failing
Starting with 26.1 Mojang ships Minecraft unobfuscated, and Yarn /
Intermediary are no longer published past 1.21.11. Loom does not need
a mappings dependency in that mode: the Mojang-named source compiles
directly. Remove the mappings line, drop the yarn_mappings property,
revert the client entrypoint to Mojang names (Minecraft, Component,
ChatFormatting, gui.getChat(), getUser().getName()), and rewrite the
README note so it stops claiming Mojang mappings via Loom.
2026-06-21 15:57:22 +06:00
71dc665ef1 Switch to Yarn mappings 26.2+build.3
Some checks failed
continuous-integration/drone/push Build is failing
Loom rejected officialMojangMappings() with a non-obfuscated
environment error, and Mojang client_mappings are not published for
26.2 yet. Pin Yarn at 26.2+build.3 via the new yarn_mappings property
and rewrite the client entrypoint with Yarn names: MinecraftClient,
Text, Formatting, inGameHud.getChatHud(), getSession().getUsername().
2026-06-21 15:49:02 +06:00
fb233cee93 Pin Loom plugin version literally for Gradle 9
Some checks failed
continuous-integration/drone/push Build is failing
Gradle 9 rejects property interpolation inside plugins {} — the version
must be a literal String. Drop the now-unused loom_version key from
gradle.properties so it does not desync from the build script.
2026-06-21 15:41:43 +06:00
77a44be34b Run IRC socket I/O on a dedicated background thread
Some checks failed
continuous-integration/drone/push Build is failing
Auto-connect on CLIENT_STARTED, .irc reconnect and .irc <msg> all used
to open a TCP socket on the render thread with a 5s connect timeout,
freezing client startup and chat input when the server was unreachable.
Dispatch those paths through a single daemon executor instead, and
release the socket plus the executor on CLIENT_STOPPING.
2026-06-21 15:40:02 +06:00
153d85602c Restore Loom mappings and modImplementation for Fabric deps
Loom needs an explicit mappings entry, and Fabric loader / API must be
declared with modImplementation so they get remapped against the active
mappings; the previous commit dropped both, which left the build with
no mappings at all and unremapped Fabric jars. Reference the
loom_version property in the plugin block too so it tracks
gradle.properties.
2026-06-21 15:39:49 +06:00
18 changed files with 874 additions and 80 deletions

View File

@@ -6,6 +6,7 @@ trigger:
event: event:
- push - push
- pull_request - pull_request
- tag
steps: steps:
- name: gradle-build - name: gradle-build
@@ -15,3 +16,18 @@ steps:
commands: commands:
- gradle --no-daemon clean build - gradle --no-daemon clean build
- ls -la build/libs - ls -la build/libs
- name: gitea-release
image: plugins/gitea-release
settings:
api_key:
from_secret: gitea_token
base_url: https://git.nevetime.ru
files:
- build/libs/*.jar
title: ${DRONE_TAG}
checksum:
- sha256
when:
event:
- tag

2
.gitignore vendored
View File

@@ -5,4 +5,6 @@ run/
*.iml *.iml
.idea/ .idea/
*.class *.class
.primer_*.md
.claude/

View File

@@ -30,7 +30,7 @@
Дата релиза `Minecraft 26.2`: `2026-06-16`. Дата релиза `Minecraft 26.2`: `2026-06-16`.
Для этой версии я использую official Mojang mappings через Loom, потому что стандартный Yarn endpoint для `26.2` на момент правки ещё не отдавал mappings. Начиная с `26.1` Mojang перестал обфусцировать клиент, а Yarn и Intermediary перестали публиковаться после `1.21.11`. Поэтому в `build.gradle` нет блока `mappings` вообще: исходники Minecraft уже в человекочитаемых именах Mojang, и Loom собирает мод напрямую против них.
Если в CI стоит старый Loom вроде `1.10.5`, сборка `26.2` упадёт с ошибкой `Unsupported class file major version 69`. Для `26.2` нужен новый Loom из актуальной ветки Fabric. Если в CI стоит старый Loom вроде `1.10.5`, сборка `26.2` упадёт с ошибкой `Unsupported class file major version 69`. Для `26.2` нужен новый Loom из актуальной ветки Fabric.
@@ -144,6 +144,27 @@ gradle build
- запуск Docker pipeline; - запуск Docker pipeline;
- `gradle clean build`; - `gradle clean build`;
- вывод содержимого `build/libs`. - вывод содержимого `build/libs`;
- по тегу — публикует jar-ы из `build/libs/*.jar` как Gitea Release.
Для работы Drone runner должен иметь доступ в интернет, чтобы скачать зависимости Gradle/Fabric. Для работы Drone runner должен иметь доступ в интернет, чтобы скачать зависимости Gradle/Fabric.
### Автоматический релиз по тегу
Чтобы Drone выложил собранный мод в раздел Releases репозитория, нужны две разовые настройки и одна команда на каждую новую версию.
**Один раз:**
1. В Gitea: `Settings → Applications → Generate New Token`. Дать токену права `write:repository`. Скопировать.
2. В Drone, на странице репозитория: `Settings → Secrets → New Secret`. Имя — `gitea_token`, значение — токен из шага 1. Поставить галку `Allow Pull Requests` снять (релизы только из push/tag).
**На каждую новую версию:**
```bash
git tag v1.0.0
git push origin v1.0.0
```
Drone поднимет тег как событие, соберёт мод и положит `client-irc-chat-<версия>.jar` в `https://git.nevetime.ru/Arkon/fdgdfg/releases/tag/v1.0.0`. Версия в имени jar-а берётся из `mod_version` в `gradle.properties` — обычно её стоит поднимать вместе с тегом, чтобы они совпадали.
Если что-то идёт не так, посмотреть лог конкретного шага `gitea-release` в Drone — плагин печатает HTTP-ответ Gitea, по нему сразу видно, проблема в токене, URL или путях к файлам.

View File

@@ -6,8 +6,7 @@ org.gradle.configuration-cache=false
minecraft_version=26.2 minecraft_version=26.2
loader_version=0.19.3 loader_version=0.19.3
fabric_version=0.152.2+26.2 fabric_version=0.152.2+26.2
loom_version=1.17-SNAPSHOT
mod_version=1.0.0 mod_version=1.0.3
maven_group=ru.nevetime maven_group=ru.nevetime
archives_base_name=client-irc-chat archives_base_name=client-irc-chat

View File

@@ -106,6 +106,46 @@ const server = net.createServer((socket) => {
continue; continue;
} }
if (payload.type === 'marker_set') {
const clientName = String(payload.client || 'UNKNOWN-CLIENT').trim();
const playerName = String(payload.player || 'unknown').trim();
const dimensionId = String(payload.dimensionId || 'minecraft:overworld').trim();
const x = Number(payload.x);
const y = Number(payload.y);
const z = Number(payload.z);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
sendJson(socket, systemMessage('Bad marker_set coords.'));
continue;
}
const outgoing = {
type: 'marker_set',
client: clientName,
player: playerName,
dimensionId,
x,
y,
z,
timestamp: Date.now()
};
console.log(`[marker_set] ${clientName} >> ${playerName} @ ${dimensionId} ${x.toFixed(1)},${y.toFixed(1)},${z.toFixed(1)}`);
broadcast(outgoing);
continue;
}
if (payload.type === 'marker_remove') {
const clientName = String(payload.client || 'UNKNOWN-CLIENT').trim();
const playerName = String(payload.player || 'unknown').trim();
const outgoing = {
type: 'marker_remove',
client: clientName,
player: playerName,
timestamp: Date.now()
};
console.log(`[marker_remove] ${clientName} >> ${playerName}`);
broadcast(outgoing);
continue;
}
sendJson(socket, systemMessage(`Unknown packet type: ${payload.type}`)); sendJson(socket, systemMessage(`Unknown packet type: ${payload.type}`));
} }
}); });

View File

@@ -1,5 +1,10 @@
package ru.nevetime.clientirc; package ru.nevetime.clientirc;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import net.fabricmc.api.ClientModInitializer; import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
@@ -8,6 +13,7 @@ import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.ChatFormatting; import net.minecraft.ChatFormatting;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -15,33 +21,80 @@ public final class ClientIrcMod implements ClientModInitializer {
public static final String MOD_ID = "clientirc"; public static final String MOD_ID = "clientirc";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private static final String COMMAND_PREFIX = ".irc"; private static final String COMMAND_PREFIX = ".";
private static final ExecutorService IO_EXECUTOR = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "clientirc-io");
thread.setDaemon(true);
return thread;
});
private static IrcConfig config; private static IrcConfig config;
private static IrcService service; private static IrcService service;
@Override @Override
public void onInitializeClient() { public void onInitializeClient() {
config = IrcConfig.load(FabricLoader.getInstance().getConfigDir().resolve("clientirc.json")); Path configDir = FabricLoader.getInstance().getConfigDir();
config = IrcConfig.load(configDir.resolve("clientirc.json"));
FriendsManager.init(configDir);
service = new IrcService(config, LOGGER); service = new IrcService(config, LOGGER);
OverlayHud.init();
WorldOverlayRenderer.init();
MiddleClickHandler.init(() -> service, () -> resolvePlayerName(Minecraft.getInstance()));
ClientLifecycleEvents.CLIENT_STARTED.register(client -> { ClientLifecycleEvents.CLIENT_STARTED.register(client -> {
if (config.autoConnect) { if (config.autoConnect) {
service.connect(resolvePlayerName(client)); String playerName = resolvePlayerName(client);
IO_EXECUTOR.execute(() -> service.connect(playerName));
} }
}); });
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> {
service.disconnect();
IO_EXECUTOR.shutdownNow();
});
ClientTickEvents.END_CLIENT_TICK.register(client -> { ClientTickEvents.END_CLIENT_TICK.register(client -> {
IrcService.DisplayLine line; IrcService.IrcEvent event;
while ((line = service.pollLine()) != null) { while ((event = service.pollEvent()) != null) {
ChatFormatting color = line.system() ? ChatFormatting.GRAY : ChatFormatting.AQUA; handleEvent(client, event);
pushClientChat(client, Component.literal(line.text()).withStyle(color));
} }
}); });
ClientSendMessageEvents.ALLOW_CHAT.register(message -> !interceptOutgoingChat(message)); ClientSendMessageEvents.ALLOW_CHAT.register(message -> !interceptOutgoingChat(message));
} }
private static void handleEvent(Minecraft client, IrcService.IrcEvent event) {
switch (event) {
case IrcService.IrcEvent.Chat chat -> {
MutableComponent line = Component.literal("[IRC] " + chat.client() + " >> ").withStyle(ChatFormatting.AQUA);
if (FriendsManager.isFriend(chat.sender())) {
line.append(Component.literal("").withStyle(ChatFormatting.GREEN));
}
line.append(Component.literal(chat.sender() + ": " + chat.message()).withStyle(ChatFormatting.AQUA));
pushClientChat(client, line);
}
case IrcService.IrcEvent.System sys ->
pushClientChat(client, Component.literal("[IRC] " + sys.message()).withStyle(ChatFormatting.GRAY));
case IrcService.IrcEvent.MarkerSet ms -> {
if (FriendsManager.isFriend(ms.sender())) {
MarkerManager.set(new MarkerManager.Marker(
ms.sender(), ms.dimensionId(), ms.x(), ms.y(), ms.z(), System.currentTimeMillis()
));
String coords = String.format(java.util.Locale.ROOT, "%.0f, %.0f, %.0f", ms.x(), ms.y(), ms.z());
pushClientChat(client, Component.literal("[IRC] marker from " + ms.sender() + " @ " + coords).withStyle(ChatFormatting.LIGHT_PURPLE));
}
}
case IrcService.IrcEvent.MarkerRemove mr -> {
if (FriendsManager.isFriend(mr.sender())) {
MarkerManager.remove(mr.sender());
pushClientChat(client, Component.literal("[IRC] marker removed by " + mr.sender()).withStyle(ChatFormatting.LIGHT_PURPLE));
}
}
}
}
private static boolean interceptOutgoingChat(String rawMessage) { private static boolean interceptOutgoingChat(String rawMessage) {
String trimmed = rawMessage.trim(); String trimmed = rawMessage.trim();
if (!trimmed.startsWith(COMMAND_PREFIX)) { if (!trimmed.startsWith(COMMAND_PREFIX)) {
@@ -49,59 +102,139 @@ public final class ClientIrcMod implements ClientModInitializer {
} }
Minecraft client = Minecraft.getInstance(); Minecraft client = Minecraft.getInstance();
String argument = trimmed.length() == COMMAND_PREFIX.length() ? "" : trimmed.substring(COMMAND_PREFIX.length()).trim(); String[] parts = trimmed.split("\\s+", 2);
String cmd = parts[0].toLowerCase(Locale.ROOT);
String rest = parts.length > 1 ? parts[1].trim() : "";
if (argument.isBlank()) { return switch (cmd) {
case ".irc" -> handleIrcCommand(client, rest);
case ".friends" -> handleFriendsCommand(client, rest);
case ".marker" -> handleMarkerCommand(client, rest);
case ".help" -> {
showHelp(client);
yield true;
}
default -> false;
};
}
private static boolean handleIrcCommand(Minecraft client, String rest) {
if (rest.isBlank()) {
pushClientChat(client, Component.literal("[IRC] Usage: .irc <message> | .irc status | .irc reconnect | .irc reload").withStyle(ChatFormatting.YELLOW)); pushClientChat(client, Component.literal("[IRC] Usage: .irc <message> | .irc status | .irc reconnect | .irc reload").withStyle(ChatFormatting.YELLOW));
return true; return true;
} }
String lower = rest.toLowerCase(Locale.ROOT);
if ("status".equalsIgnoreCase(argument)) { if ("status".equals(lower)) {
String status = service.isConnected() ? "connected" : "disconnected"; String status = service.isConnected() ? "connected" : "disconnected";
pushClientChat(client, Component.literal("[IRC] " + status + " | " + config.host + ":" + config.port + " | clientName=" + config.clientName).withStyle(ChatFormatting.YELLOW)); pushClientChat(client, Component.literal("[IRC] " + status + " | " + config.host + ":" + config.port + " | clientName=" + config.clientName).withStyle(ChatFormatting.YELLOW));
return true; return true;
} }
if ("reconnect".equals(lower)) {
if ("reconnect".equalsIgnoreCase(argument)) { String playerName = resolvePlayerName(client);
boolean connected = service.reconnect(resolvePlayerName(client)); IO_EXECUTOR.execute(() -> {
ChatFormatting color = connected ? ChatFormatting.GREEN : ChatFormatting.RED; boolean connected = service.reconnect(playerName);
pushClientChat(client, Component.literal("[IRC] reconnect " + (connected ? "ok" : "failed")).withStyle(color)); ChatFormatting color = connected ? ChatFormatting.GREEN : ChatFormatting.RED;
pushClientChat(client, Component.literal("[IRC] reconnect " + (connected ? "ok" : "failed")).withStyle(color));
});
return true; return true;
} }
if ("reload".equals(lower)) {
if ("reload".equalsIgnoreCase(argument)) {
config = IrcConfig.load(FabricLoader.getInstance().getConfigDir().resolve("clientirc.json")); config = IrcConfig.load(FabricLoader.getInstance().getConfigDir().resolve("clientirc.json"));
service.updateConfig(config); service.updateConfig(config);
pushClientChat(client, Component.literal("[IRC] config reloaded").withStyle(ChatFormatting.YELLOW)); pushClientChat(client, Component.literal("[IRC] config reloaded").withStyle(ChatFormatting.YELLOW));
return true; return true;
} }
String playerName = resolvePlayerName(client);
IO_EXECUTOR.execute(() -> {
if (!service.ensureConnected(playerName)) {
pushClientChat(client, Component.literal("[IRC] server is offline or config is wrong").withStyle(ChatFormatting.RED));
return;
}
if (!service.sendChat(playerName, rest)) {
pushClientChat(client, Component.literal("[IRC] send failed").withStyle(ChatFormatting.RED));
}
});
return true;
}
if (!service.ensureConnected(resolvePlayerName(client))) { private static boolean handleFriendsCommand(Minecraft client, String rest) {
pushClientChat(client, Component.literal("[IRC] server is offline or config is wrong").withStyle(ChatFormatting.RED)); if (rest.isBlank()) {
pushClientChat(client, Component.literal("[IRC] Usage: .friends add <name> | .friends remove <name> | .friends list").withStyle(ChatFormatting.YELLOW));
return true; return true;
} }
String[] parts = rest.split("\\s+", 2);
String sub = parts[0].toLowerCase(Locale.ROOT);
String arg = parts.length > 1 ? parts[1].trim() : "";
boolean sent = service.sendChat(resolvePlayerName(client), argument); if ("add".equals(sub)) {
if (!sent) { if (arg.isBlank()) {
pushClientChat(client, Component.literal("[IRC] send failed").withStyle(ChatFormatting.RED)); pushClientChat(client, Component.literal("[IRC] usage: .friends add <name>").withStyle(ChatFormatting.YELLOW));
return true;
}
boolean added = FriendsManager.add(arg);
pushClientChat(client, Component.literal("[IRC] " + (added ? "added friend " + arg : arg + " is already a friend"))
.withStyle(added ? ChatFormatting.GREEN : ChatFormatting.YELLOW));
return true;
} }
if ("remove".equals(sub)) {
if (arg.isBlank()) {
pushClientChat(client, Component.literal("[IRC] usage: .friends remove <name>").withStyle(ChatFormatting.YELLOW));
return true;
}
boolean removed = FriendsManager.remove(arg);
pushClientChat(client, Component.literal("[IRC] " + (removed ? "removed friend " + arg : arg + " was not a friend"))
.withStyle(removed ? ChatFormatting.GREEN : ChatFormatting.YELLOW));
return true;
}
if ("list".equals(sub)) {
List<String> friends = FriendsManager.list();
if (friends.isEmpty()) {
pushClientChat(client, Component.literal("[IRC] friends list is empty").withStyle(ChatFormatting.YELLOW));
} else {
pushClientChat(client, Component.literal("[IRC] friends (" + friends.size() + "): " + String.join(", ", friends))
.withStyle(ChatFormatting.GREEN));
}
return true;
}
pushClientChat(client, Component.literal("[IRC] unknown subcommand. Use add/remove/list.").withStyle(ChatFormatting.YELLOW));
return true; return true;
} }
private static boolean handleMarkerCommand(Minecraft client, String rest) {
String lower = rest.toLowerCase(Locale.ROOT);
if ("remove".equals(lower)) {
MiddleClickHandler.removeOwnMarker(() -> service, () -> resolvePlayerName(client));
pushClientChat(client, Component.literal("[IRC] your marker removed").withStyle(ChatFormatting.YELLOW));
return true;
}
pushClientChat(client, Component.literal("[IRC] Usage: middle-click to drop a marker (friends only see it) | .marker remove").withStyle(ChatFormatting.YELLOW));
return true;
}
private static void showHelp(Minecraft client) {
pushClientChat(client, Component.literal("[IRC] Commands:").withStyle(ChatFormatting.YELLOW));
pushClientChat(client, Component.literal(" .irc <msg> | .irc status | .irc reconnect | .irc reload").withStyle(ChatFormatting.GRAY));
pushClientChat(client, Component.literal(" .friends add <name> | .friends remove <name> | .friends list").withStyle(ChatFormatting.GRAY));
pushClientChat(client, Component.literal(" MMB drops a marker (friends only see it) | .marker remove").withStyle(ChatFormatting.GRAY));
pushClientChat(client, Component.literal(" .help shows this list").withStyle(ChatFormatting.GRAY));
}
private static void pushClientChat(Minecraft client, Component text) { private static void pushClientChat(Minecraft client, Component text) {
if (client == null) { if (client == null) {
return; return;
} }
client.execute(() -> { client.execute(() -> {
if (client.gui != null && client.gui.getChat() != null) { if (client.player != null) {
client.gui.getChat().addMessage(text); client.player.sendSystemMessage(text);
} else {
LOGGER.info(text.getString());
} }
}); });
} }
private static String resolvePlayerName(Minecraft client) { private static String resolvePlayerName(Minecraft client) {
if (client != null && client.player != null) { if (client != null && client.player != null) {
return client.player.getGameProfile().getName(); return client.player.getGameProfile().name();
} }
if (client != null && client.getUser() != null) { if (client != null && client.getUser() != null) {
return client.getUser().getName(); return client.getUser().getName();

View File

@@ -0,0 +1,56 @@
package ru.nevetime.clientirc;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class MarkerManager {
private static final long EXPIRY_MS = 5L * 60L * 1000L;
private static final ConcurrentHashMap<String, Marker> MARKERS = new ConcurrentHashMap<>();
private MarkerManager() {
}
public record Marker(String owner, String dimensionId, double x, double y, double z, long createdAtMs) {
}
public static void set(Marker marker) {
if (marker == null || marker.owner() == null || marker.owner().isBlank()) {
return;
}
MARKERS.put(marker.owner().toLowerCase(Locale.ROOT), marker);
}
public static void remove(String owner) {
if (owner == null || owner.isBlank()) {
return;
}
MARKERS.remove(owner.toLowerCase(Locale.ROOT));
}
public static void clear() {
MARKERS.clear();
}
public static Collection<Marker> active() {
long now = System.currentTimeMillis();
ArrayList<Marker> alive = new ArrayList<>(MARKERS.size());
Iterator<Map.Entry<String, Marker>> it = MARKERS.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Marker> entry = it.next();
if (now - entry.getValue().createdAtMs() > EXPIRY_MS) {
it.remove();
} else {
alive.add(entry.getValue());
}
}
return alive;
}
public static long expiryMs() {
return EXPIRY_MS;
}
}

View File

@@ -0,0 +1,71 @@
package ru.nevetime.clientirc;
import com.mojang.blaze3d.platform.InputConstants;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
import net.minecraft.client.KeyMapping;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.HitResult;
import net.minecraft.world.phys.Vec3;
import org.lwjgl.glfw.GLFW;
public final class MiddleClickHandler {
private static final ExecutorService IO_EXECUTOR =
Executors.newSingleThreadExecutor(runnable -> {
Thread t = new Thread(runnable, "clientirc-marker-io");
t.setDaemon(true);
return t;
});
private static KeyMapping markerKey;
private MiddleClickHandler() {}
public static void init(Supplier<IrcService> serviceSupplier, Supplier<String> playerNameSupplier) {
markerKey = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.clientirc.marker",
InputConstants.Type.MOUSE,
GLFW.GLFW_MOUSE_BUTTON_MIDDLE,
KeyMapping.Category.MISC
));
ClientTickEvents.END_CLIENT_TICK.register(client -> {
while (markerKey.consumeClick()) {
if (client.player == null || client.level == null) break;
Vec3 target;
if (client.hitResult instanceof BlockHitResult br && br.getType() != HitResult.Type.MISS) {
var bp = br.getBlockPos();
target = new Vec3(bp.getX() + 0.5, bp.getY() + 0.5, bp.getZ() + 0.5);
} else {
Vec3 eye = client.player.getEyePosition();
Vec3 look = client.player.getViewVector(1.0f);
target = eye.add(look.scale(20.0));
}
String dimId = client.level.dimension().identifier().toString();
String playerName = playerNameSupplier.get();
double tx = target.x, ty = target.y, tz = target.z;
MarkerManager.set(new MarkerManager.Marker(playerName, dimId, tx, ty, tz, System.currentTimeMillis()));
IO_EXECUTOR.execute(() -> {
IrcService service = serviceSupplier.get();
if (service != null) service.sendMarkerSet(playerName, dimId, tx, ty, tz);
});
}
});
}
public static void removeOwnMarker(Supplier<IrcService> serviceSupplier, Supplier<String> playerNameSupplier) {
String name = playerNameSupplier.get();
MarkerManager.remove(name);
IO_EXECUTOR.execute(() -> {
IrcService service = serviceSupplier.get();
if (service != null) service.sendMarkerRemove(name);
});
}
}

View File

@@ -0,0 +1,103 @@
package ru.nevetime.clientirc;
import com.mojang.authlib.GameProfile;
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElement;
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
import net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.multiplayer.PlayerInfo;
import net.minecraft.resources.Identifier;
public final class OverlayHud {
private static final Identifier COMPASS_LAYER = Identifier.fromNamespaceAndPath("clientirc", "compass");
private static final Identifier WATERMARK_LAYER = Identifier.fromNamespaceAndPath("clientirc", "watermark");
private static final int COMPASS_WIDTH = 180;
private static final int COMPASS_HEIGHT = 14;
private static final int COMPASS_Y = 2;
private OverlayHud() {}
public static void init() {
HudElementRegistry.attachElementAfter(
VanillaHudElements.MISC_OVERLAYS, COMPASS_LAYER, (HudElement) OverlayHud::renderCompass);
HudElementRegistry.attachElementAfter(
VanillaHudElements.MISC_OVERLAYS, WATERMARK_LAYER, (HudElement) OverlayHud::renderWatermark);
}
private static void renderCompass(GuiGraphicsExtractor graphics, DeltaTracker deltaTracker) {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null) return;
Font font = mc.font;
int x = (graphics.guiWidth() - COMPASS_WIDTH) / 2;
int y = COMPASS_Y;
graphics.fill(x, y, x + COMPASS_WIDTH, y + COMPASS_HEIGHT, 0x80000000);
float yaw = mc.gameRenderer.mainCamera().yRot();
int[] cardYaws = {180, -90, 0, 90};
String[] cardLabels = {"N", "E", "S", "W"};
for (int i = 0; i < cardYaws.length; i++) {
float delta = wrapDegrees(cardYaws[i] - yaw);
if (Math.abs(delta) > 90.0f) continue;
int px = x + 90 + (int) delta;
int color = Math.abs(delta) <= 5.0f ? 0xFFFFFF55 : 0xFFFFFFFF;
int labelWidth = font.width(cardLabels[i]);
graphics.text(font, cardLabels[i], px - labelWidth / 2, y + 3, color, true);
}
graphics.fill(x + 89, y + 11, x + 92, y + 14, 0xFFFFFFFF);
}
private static void renderWatermark(GuiGraphicsExtractor graphics, DeltaTracker deltaTracker) {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null) return;
Font font = mc.font;
int x = 4;
int y = 4;
int lineHeight = font.lineHeight + 2;
String nick = resolveNick(mc);
int fps = mc.getFps();
int ping = resolvePing(mc);
graphics.text(font, nick, x, y, 0xFFFFFFFF, true);
graphics.text(font, "FPS: " + fps, x, y + lineHeight, 0xFFFFFFFF, true);
graphics.text(font, "Ping: " + ping + " ms", x, y + 2 * lineHeight, 0xFFFFFFFF, true);
}
private static String resolveNick(Minecraft mc) {
if (mc.player != null) {
GameProfile profile = mc.player.getGameProfile();
if (profile != null && profile.name() != null && !profile.name().isBlank()) {
return profile.name();
}
}
if (mc.getUser() != null) {
String name = mc.getUser().getName();
if (name != null && !name.isBlank()) {
return name;
}
}
return "";
}
private static int resolvePing(Minecraft mc) {
if (mc.getConnection() == null || mc.getUser() == null) return 0;
PlayerInfo info = mc.getConnection().getPlayerInfo(mc.getUser().getProfileId());
return info == null ? 0 : info.getLatency();
}
private static float wrapDegrees(float v) {
v = v % 360.0f;
if (v >= 180.0f) v -= 360.0f;
if (v < -180.0f) v += 360.0f;
return v;
}
}

View File

@@ -0,0 +1,78 @@
package ru.nevetime.clientirc;
import com.mojang.authlib.GameProfile;
import com.mojang.blaze3d.vertex.PoseStack;
import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderContext;
import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.OrderedSubmitNodeCollector;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.shapes.Shapes;
import net.minecraft.world.phys.shapes.VoxelShape;
public final class WorldOverlayRenderer {
private static final int FRIEND_COLOR = 0xFF33FF55;
private static final int MARKER_COLOR = 0xFFFF55FF;
private static final float OUTLINE_WIDTH = 2.0f;
private static final double BEAM_HEIGHT = 50.0;
private WorldOverlayRenderer() {}
public static void init() {
LevelRenderEvents.AFTER_TRANSLUCENT_FEATURES.register(WorldOverlayRenderer::onRender);
}
private static void onRender(LevelRenderContext context) {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null || mc.player == null) return;
SubmitNodeCollector base = context.submitNodeCollector();
if (!(base instanceof OrderedSubmitNodeCollector collector)) return;
PoseStack pose = context.poseStack();
Vec3 camPos = mc.gameRenderer.mainCamera().position();
double cx = camPos.x;
double cy = camPos.y;
double cz = camPos.z;
for (Player player : mc.level.players()) {
if (player == mc.player) continue;
GameProfile profile = player.getGameProfile();
if (profile == null) continue;
if (!FriendsManager.isFriend(profile.name())) continue;
double px = player.getX() - cx;
double py = player.getY() + player.getBbHeight() + 0.3 - cy;
double pz = player.getZ() - cz;
pose.pushPose();
pose.translate(px - 0.15, py - 0.15, pz - 0.15);
VoxelShape dot = Shapes.box(0.0, 0.0, 0.0, 0.3, 0.3, 0.3);
collector.submitShapeOutline(pose, dot, RenderTypes.lines(), FRIEND_COLOR, OUTLINE_WIDTH, false);
pose.popPose();
}
String currentDim = mc.level.dimension().identifier().toString();
for (MarkerManager.Marker marker : MarkerManager.active()) {
if (!currentDim.equals(marker.dimensionId())) continue;
double mx = marker.x() - cx;
double my = marker.y() - cy;
double mz = marker.z() - cz;
pose.pushPose();
pose.translate(mx - 0.5, my, mz - 0.5);
collector.submitShapeOutline(pose, Shapes.block(), RenderTypes.lines(), MARKER_COLOR, OUTLINE_WIDTH, false);
pose.popPose();
pose.pushPose();
pose.translate(mx - 0.05, my, mz - 0.05);
VoxelShape beam = Shapes.box(0.0, 0.0, 0.0, 0.1, BEAM_HEIGHT, 0.1);
collector.submitShapeOutline(pose, beam, RenderTypes.lines(), MARKER_COLOR, OUTLINE_WIDTH, false);
pose.popPose();
}
}
}

View File

@@ -0,0 +1,50 @@
package ru.nevetime.clientirc.mixin;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.ChatScreen;
import net.minecraft.client.input.KeyEvent;
import org.lwjgl.glfw.GLFW;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(ChatScreen.class)
public abstract class ChatScreenMixin {
@Shadow protected EditBox input;
private static final String[] CLIENTIRC_COMMANDS = new String[] {
".irc",
".irc status",
".irc reconnect",
".irc reload",
".friends add ",
".friends remove ",
".friends list",
".marker remove",
".help"
};
@Inject(method = "keyPressed", at = @At("HEAD"), cancellable = true)
private void clientirc$tabComplete(KeyEvent event, CallbackInfoReturnable<Boolean> cir) {
if (event.key() != GLFW.GLFW_KEY_TAB) return;
if (input == null) return;
String value = input.getValue();
if (value == null || !value.startsWith(".")) return;
String best = null;
for (String candidate : CLIENTIRC_COMMANDS) {
if (candidate.startsWith(value) && !candidate.equals(value)) {
if (best == null || candidate.length() < best.length()) {
best = candidate;
}
}
}
if (best != null) {
input.setValue(best);
input.setCursorPosition(best.length());
cir.setReturnValue(true);
}
}
}

View File

@@ -0,0 +1,11 @@
{
"required": true,
"package": "ru.nevetime.clientirc.mixin",
"compatibilityLevel": "JAVA_21",
"client": [
"ChatScreenMixin"
],
"injectors": {
"defaultRequire": 1
}
}

View File

@@ -0,0 +1,129 @@
package ru.nevetime.clientirc;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class FriendsManager {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Logger LOGGER = LoggerFactory.getLogger(FriendsManager.class);
private static final ConcurrentHashMap<String, String> FRIENDS = new ConcurrentHashMap<>();
private static volatile Path file;
private FriendsManager() {
}
public static void init(Path configDir) {
file = configDir.resolve("clientirc-friends.json");
try {
Files.createDirectories(file.getParent());
} catch (IOException ex) {
LOGGER.warn("Failed to create config directory for friends", ex);
}
load();
}
public static boolean add(String name) {
if (name == null || name.isBlank() || file == null) {
return false;
}
String trimmed = name.trim();
String key = trimmed.toLowerCase(Locale.ROOT);
if (FRIENDS.putIfAbsent(key, trimmed) != null) {
return false;
}
save();
return true;
}
public static boolean remove(String name) {
if (name == null || name.isBlank() || file == null) {
return false;
}
String key = name.trim().toLowerCase(Locale.ROOT);
if (FRIENDS.remove(key) == null) {
return false;
}
save();
return true;
}
public static boolean isFriend(String name) {
if (name == null || name.isBlank()) {
return false;
}
return FRIENDS.containsKey(name.trim().toLowerCase(Locale.ROOT));
}
public static List<String> list() {
List<String> all = new ArrayList<>(FRIENDS.values());
all.sort(Comparator.comparing(s -> s.toLowerCase(Locale.ROOT)));
return all;
}
private static void load() {
if (file == null) {
return;
}
if (!Files.exists(file)) {
save();
return;
}
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
Storage storage = GSON.fromJson(reader, Storage.class);
FRIENDS.clear();
if (storage != null && storage.friends != null) {
for (String name : storage.friends) {
if (name == null) {
continue;
}
String trimmed = name.trim();
if (trimmed.isEmpty()) {
continue;
}
FRIENDS.put(trimmed.toLowerCase(Locale.ROOT), trimmed);
}
}
} catch (IOException | JsonParseException ex) {
LOGGER.warn("Failed to load friends list from {}", file, ex);
FRIENDS.clear();
save();
}
}
private static void save() {
if (file == null) {
return;
}
Storage storage = new Storage();
storage.friends = list();
try {
Files.createDirectories(file.getParent());
try (Writer writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
GSON.toJson(storage, writer);
}
} catch (IOException ex) {
LOGGER.warn("Failed to save friends list to {}", file, ex);
}
}
private static final class Storage {
@SerializedName("friends")
List<String> friends;
}
}

View File

@@ -16,10 +16,10 @@ public final class IrcConfig {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Logger LOGGER = LoggerFactory.getLogger(IrcConfig.class); private static final Logger LOGGER = LoggerFactory.getLogger(IrcConfig.class);
public String host = "127.0.0.1"; public String host = "2.26.99.24";
public int port = 24469; public int port = 24469;
public String clientName = "MY-CLIENT"; public String clientName = "CLIENT";
public String token = ""; public String token = "0etnVzgvmZzHj8H09X8xO6gf8L7qN9DZktYuSTwg";
public boolean autoConnect = true; public boolean autoConnect = true;
public int connectTimeoutMs = 5000; public int connectTimeoutMs = 5000;

View File

@@ -6,27 +6,55 @@ public final class IrcMessage {
public String player; public String player;
public String message; public String message;
public String token; public String token;
public String dimensionId;
public Double x;
public Double y;
public Double z;
public long timestamp; public long timestamp;
public static IrcMessage hello(String clientName, String playerName, String token) { public static IrcMessage hello(String clientName, String playerName, String token) {
IrcMessage message = new IrcMessage(); IrcMessage m = new IrcMessage();
message.type = "hello"; m.type = "hello";
message.client = clientName; m.client = clientName;
message.player = playerName; m.player = playerName;
message.token = token; m.token = token;
message.timestamp = System.currentTimeMillis(); m.timestamp = System.currentTimeMillis();
return message; return m;
} }
public static IrcMessage chat(String clientName, String playerName, String text, String token) { public static IrcMessage chat(String clientName, String playerName, String text, String token) {
IrcMessage message = new IrcMessage(); IrcMessage m = new IrcMessage();
message.type = "chat"; m.type = "chat";
message.client = clientName; m.client = clientName;
message.player = playerName; m.player = playerName;
message.message = text; m.message = text;
message.token = token; m.token = token;
message.timestamp = System.currentTimeMillis(); m.timestamp = System.currentTimeMillis();
return message; return m;
}
public static IrcMessage markerSet(String clientName, String playerName, String token,
String dimensionId, double x, double y, double z) {
IrcMessage m = new IrcMessage();
m.type = "marker_set";
m.client = clientName;
m.player = playerName;
m.token = token;
m.dimensionId = dimensionId;
m.x = x;
m.y = y;
m.z = z;
m.timestamp = System.currentTimeMillis();
return m;
}
public static IrcMessage markerRemove(String clientName, String playerName, String token) {
IrcMessage m = new IrcMessage();
m.type = "marker_remove";
m.client = clientName;
m.player = playerName;
m.token = token;
m.timestamp = System.currentTimeMillis();
return m;
} }
} }

View File

@@ -19,7 +19,7 @@ public final class IrcService {
private static final Gson GSON = new Gson(); private static final Gson GSON = new Gson();
private final Logger logger; private final Logger logger;
private final ConcurrentLinkedQueue<DisplayLine> queue = new ConcurrentLinkedQueue<>(); private final ConcurrentLinkedQueue<IrcEvent> queue = new ConcurrentLinkedQueue<>();
private volatile IrcConfig config; private volatile IrcConfig config;
private volatile Socket socket; private volatile Socket socket;
@@ -33,6 +33,20 @@ public final class IrcService {
this.logger = Objects.requireNonNull(logger, "logger"); this.logger = Objects.requireNonNull(logger, "logger");
} }
public sealed interface IrcEvent {
record Chat(String client, String sender, String message) implements IrcEvent {
}
record System(String message) implements IrcEvent {
}
record MarkerSet(String sender, String dimensionId, double x, double y, double z) implements IrcEvent {
}
record MarkerRemove(String sender) implements IrcEvent {
}
}
public synchronized void updateConfig(IrcConfig config) { public synchronized void updateConfig(IrcConfig config) {
this.config = Objects.requireNonNull(config, "config"); this.config = Objects.requireNonNull(config, "config");
} }
@@ -66,12 +80,12 @@ public final class IrcService {
sendInternal(IrcMessage.hello(config.clientName, playerName, config.token)); sendInternal(IrcMessage.hello(config.clientName, playerName, config.token));
enqueueSystem("Connected to " + config.host + ":" + config.port); enqueueSystem("Connected to " + config.host + ":" + config.port);
return true; return true;
} catch (IOException exception) { } catch (IOException ex) {
closeQuietly(newReader); closeQuietly(newReader);
closeQuietly(newWriter); closeQuietly(newWriter);
closeQuietly(newSocket); closeQuietly(newSocket);
enqueueSystem("Connection failed: " + exception.getMessage()); enqueueSystem("Connection failed: " + ex.getMessage());
logger.warn("IRC connection failed", exception); logger.warn("IRC connection failed", ex);
return false; return false;
} }
} }
@@ -90,13 +104,44 @@ public final class IrcService {
if (!isConnected()) { if (!isConnected()) {
return false; return false;
} }
try { try {
sendInternal(IrcMessage.chat(config.clientName, playerName, text, config.token)); sendInternal(IrcMessage.chat(config.clientName, playerName, text, config.token));
return true; return true;
} catch (IOException exception) { } catch (IOException ex) {
enqueueSystem("Send failed: " + exception.getMessage()); enqueueSystem("Send failed: " + ex.getMessage());
logger.warn("Failed to send IRC message", exception); logger.warn("Failed to send IRC chat", ex);
running = false;
closeSocketState();
return false;
}
}
public synchronized boolean sendMarkerSet(String playerName, String dimensionId, double x, double y, double z) {
if (!isConnected()) {
return false;
}
try {
sendInternal(IrcMessage.markerSet(config.clientName, playerName, config.token, dimensionId, x, y, z));
return true;
} catch (IOException ex) {
enqueueSystem("Send failed: " + ex.getMessage());
logger.warn("Failed to send IRC marker_set", ex);
running = false;
closeSocketState();
return false;
}
}
public synchronized boolean sendMarkerRemove(String playerName) {
if (!isConnected()) {
return false;
}
try {
sendInternal(IrcMessage.markerRemove(config.clientName, playerName, config.token));
return true;
} catch (IOException ex) {
enqueueSystem("Send failed: " + ex.getMessage());
logger.warn("Failed to send IRC marker_remove", ex);
running = false; running = false;
closeSocketState(); closeSocketState();
return false; return false;
@@ -107,7 +152,7 @@ public final class IrcService {
return socket != null && socket.isConnected() && !socket.isClosed() && writer != null; return socket != null && socket.isConnected() && !socket.isClosed() && writer != null;
} }
public DisplayLine pollLine() { public IrcEvent pollEvent() {
return queue.poll(); return queue.poll();
} }
@@ -124,14 +169,13 @@ public final class IrcService {
while (running && (line = activeReader.readLine()) != null) { while (running && (line = activeReader.readLine()) != null) {
handleIncomingLine(line); handleIncomingLine(line);
} }
if (running) { if (running) {
enqueueSystem("Server closed the connection."); enqueueSystem("Server closed the connection.");
} }
} catch (IOException exception) { } catch (IOException ex) {
if (running) { if (running) {
enqueueSystem("Disconnected: " + exception.getMessage()); enqueueSystem("Disconnected: " + ex.getMessage());
logger.warn("IRC reader stopped", exception); logger.warn("IRC reader stopped", ex);
} }
} finally { } finally {
running = false; running = false;
@@ -148,25 +192,39 @@ public final class IrcService {
return; return;
} }
if ("chat".equals(message.type)) { switch (message.type) {
String clientName = safeValue(message.client, "UNKNOWN-CLIENT"); case "chat" -> {
String playerName = safeValue(message.player, "unknown"); String clientName = safeValue(message.client, "UNKNOWN-CLIENT");
String text = safeValue(message.message, ""); String sender = safeValue(message.player, "unknown");
queue.add(new DisplayLine("[IRC] " + clientName + " >> " + playerName + ": " + text, false)); String text = safeValue(message.message, "");
return; queue.add(new IrcEvent.Chat(clientName, sender, text));
}
case "system" -> {
if (message.message != null && !message.message.isBlank()) {
queue.add(new IrcEvent.System(message.message));
}
}
case "marker_set" -> {
if (message.x != null && message.y != null && message.z != null && message.dimensionId != null) {
String sender = safeValue(message.player, "unknown");
queue.add(new IrcEvent.MarkerSet(sender, message.dimensionId, message.x, message.y, message.z));
}
}
case "marker_remove" -> {
String sender = safeValue(message.player, "unknown");
queue.add(new IrcEvent.MarkerRemove(sender));
}
default -> {
}
} }
} catch (JsonParseException ex) {
if ("system".equals(message.type) && message.message != null && !message.message.isBlank()) {
enqueueSystem(message.message);
}
} catch (JsonParseException exception) {
enqueueSystem("Malformed packet: " + rawLine); enqueueSystem("Malformed packet: " + rawLine);
logger.warn("Failed to parse IRC packet: {}", rawLine, exception); logger.warn("Failed to parse IRC packet: {}", rawLine, ex);
} }
} }
private void enqueueSystem(String text) { private void enqueueSystem(String text) {
queue.add(new DisplayLine("[IRC] " + text, true)); queue.add(new IrcEvent.System(text));
} }
private void sendInternal(IrcMessage message) throws IOException { private void sendInternal(IrcMessage message) throws IOException {
@@ -189,7 +247,6 @@ public final class IrcService {
if (closeable == null) { if (closeable == null) {
return; return;
} }
try { try {
closeable.close(); closeable.close();
} catch (IOException ignored) { } catch (IOException ignored) {
@@ -199,8 +256,4 @@ public final class IrcService {
private static String safeValue(String value, String fallback) { private static String safeValue(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value; return value == null || value.isBlank() ? fallback : value;
} }
public record DisplayLine(String text, boolean system) {
}
} }

View File

@@ -15,6 +15,9 @@
"ru.nevetime.clientirc.ClientIrcMod" "ru.nevetime.clientirc.ClientIrcMod"
] ]
}, },
"mixins": [
{ "config": "clientirc.client.mixins.json", "environment": "client" }
],
"depends": { "depends": {
"fabricloader": ">=0.19.3", "fabricloader": ">=0.19.3",
"minecraft": "26.2", "minecraft": "26.2",

File diff suppressed because one or more lines are too long