diff --git a/mod/src/main/java/dev/loki/lovisual/features/command/impl/client/ConfigCommand.java b/mod/src/main/java/dev/loki/lovisual/features/command/impl/client/ConfigCommand.java index 8b6244b..ea02536 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/command/impl/client/ConfigCommand.java +++ b/mod/src/main/java/dev/loki/lovisual/features/command/impl/client/ConfigCommand.java @@ -7,6 +7,7 @@ import dev.loki.lovisual.features.command.core.CommandContext; import dev.loki.lovisual.features.command.core.CommandInfo; import dev.loki.lovisual.features.command.util.CommandOutput; import dev.loki.lovisual.features.module.core.ModuleManager; +import dev.loki.lovisual.features.platform.config.ConfigCloudUploader; import dev.loki.lovisual.features.platform.config.ConfigShareLoader; import java.util.List; @@ -15,7 +16,7 @@ import java.util.Locale; @CommandInfo( id = "config", aliases = {"cfg", "settings"}, - usage = "%config [save|load [code]|path]", + usage = "%config [save [1-4 name]|load [code]|path]", descriptionKey = "command.config.description" ) public final class ConfigCommand implements ClientCommand { @@ -24,6 +25,13 @@ public final class ConfigCommand implements ClientCommand { String action = ctx.arg(0) == null ? "save" : ctx.arg(0).toLowerCase(Locale.ROOT); switch (action) { case "save" -> { + String slotArg = ctx.arg(1); + if (slotArg != null && slotArg.matches("[1-4]")) { + // %config save <1-4> [name]: snapshot current modules and + // upload to the linked account's cloud slot (PUT /configs/{slot}). + ConfigCloudUploader.upload(Integer.parseInt(slotArg), ctx.arg(2)); + break; + } ModuleManager.saveAllModuleConfigs(); ConfigSerializer.saveAll(); CommandOutput.success("Client config saved."); diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigCloudUploader.java b/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigCloudUploader.java new file mode 100644 index 0000000..b34dcf2 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigCloudUploader.java @@ -0,0 +1,80 @@ +package dev.loki.lovisual.features.platform.config; + +import com.google.gson.JsonObject; +import dev.loki.lovisual.config.profile.codec.ConfigProfileType; +import dev.loki.lovisual.config.profile.service.ConfigProfileCollector; +import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot; +import dev.loki.lovisual.features.command.util.CommandOutput; +import dev.loki.lovisual.features.platform.PlatformSession; +import dev.loki.lovisual.features.platform.http.ApiResult; +import dev.loki.lovisual.features.platform.http.PlatformHttpClient; +import dev.loki.lovisual.features.platform.json.ConfigProfileJsonCodec; +import net.minecraft.client.Minecraft; + +/** + * Backs {@code %config save }: snapshots the current module settings and + * uploads them to the authenticated account's cloud slot through + * {@code PUT /configs/{slot}} (gateway forwards it to configs-service, which + * returns the slot's {@code share_code}). Complements {@link ConfigShareLoader} + * ({@code %config load }) — the pair is what makes a config portable: + * upload here yields the code a friend redeems there. Kept out of + * {@code ConfigCommand} for the same line-budget/package-cohesion reasons. + */ +public final class ConfigCloudUploader { + + private static final ConfigProfileCollector COLLECTOR = new ConfigProfileCollector(); + + private ConfigCloudUploader() { + } + + /** + * Wraps a snapshot in the {@code {name, data}} body configs-service expects + * on {@code PUT /configs/{slot}}. Split out from {@link #upload} so it is + * unit-testable without a live backend or a running client. + */ + public static JsonObject buildBody(String name, ConfigProfileSnapshot snapshot) { + JsonObject body = new JsonObject(); + body.addProperty("name", name); + body.add("data", ConfigProfileJsonCodec.toJson(snapshot)); + return body; + } + + public static void upload(int slot, String rawName) { + if (slot < 1 || slot > 4) { + CommandOutput.warning("Slot must be 1..4. Usage: %config save <1-4> [name]"); + return; + } + if (!PlatformSession.get().isLinked()) { + CommandOutput.error("Not linked to a platform account. Run %link first."); + return; + } + String name = (rawName == null || rawName.isBlank()) ? ("Slot " + slot) : rawName.trim(); + ConfigProfileSnapshot snapshot = COLLECTOR.collect(ConfigProfileType.MODULES, name); + CommandOutput.send("Uploading \"" + name + "\" to cloud slot " + slot + "..."); + PlatformHttpClient.put("/configs/" + slot, buildBody(name, snapshot), PlatformSession.get().getDeviceToken()) + .thenAccept(result -> onUploaded(slot, result)); + } + + private static void onUploaded(int slot, ApiResult result) { + dispatch(() -> { + if (!result.success() || result.asObject() == null) { + CommandOutput.error("Cloud upload failed: " + result.error()); + return; + } + JsonObject json = result.asObject(); + String shareCode = json.has("share_code") && !json.get("share_code").isJsonNull() + ? json.get("share_code").getAsString() : "?"; + CommandOutput.success("Saved slot " + slot + ". Share code: " + shareCode + + " (friend uses %config load " + shareCode + ")."); + }); + } + + private static void dispatch(Runnable runnable) { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.isSameThread()) { + runnable.run(); + } else { + mc.execute(runnable); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/http/PlatformHttpClient.java b/mod/src/main/java/dev/loki/lovisual/features/platform/http/PlatformHttpClient.java index 2f44bb5..9e5c574 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/platform/http/PlatformHttpClient.java +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/http/PlatformHttpClient.java @@ -52,6 +52,14 @@ public final class PlatformHttpClient { return send(builder, bearerToken); } + public static CompletableFuture put(String path, JsonObject body, String bearerToken) { + String json = body == null ? "{}" : body.toString(); + HttpRequest.Builder builder = requestBuilder(path) + .header("Content-Type", "application/json") + .PUT(HttpRequest.BodyPublishers.ofString(json)); + return send(builder, bearerToken); + } + public static CompletableFuture delete(String path, String bearerToken) { return send(requestBuilder(path).DELETE(), bearerToken); } diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/json/ConfigProfileJsonCodec.java b/mod/src/main/java/dev/loki/lovisual/features/platform/json/ConfigProfileJsonCodec.java index 12d1cd0..4141873 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/platform/json/ConfigProfileJsonCodec.java +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/json/ConfigProfileJsonCodec.java @@ -25,7 +25,8 @@ import java.util.Map; * compressed, CRC32-checked binary blob written by * {@code ConfigProfileBinaryCodec}, which is not what a JSONB column can * hold. The backend's own plan docs do not yet define an exact shape for - * {@code data} (as of this pass, mod-side upload is still unimplemented, and + * {@code data} (as of this pass, mod-side upload rides on this same shape via + * {@code ConfigCloudUploader}, and * {@code configs-service/PLAN.md} only fixes its own easter-egg payload as * an example) so the shape below ({@code {meta:{...}, entries:[{ownerId, * displayName, values}]}}) is this task's judgment call: it mirrors diff --git a/mod/src/main/resources/assets/lovisual/lang/en_us.json b/mod/src/main/resources/assets/lovisual/lang/en_us.json index 5fa436b..5a38475 100644 --- a/mod/src/main/resources/assets/lovisual/lang/en_us.json +++ b/mod/src/main/resources/assets/lovisual/lang/en_us.json @@ -3248,7 +3248,7 @@ "setting.nointeract.only_kill_aura": "Only with KillAura", "command.addons.description": "Lists, scans, enables, or disables client addons.", "command.bind.description": "Changes module key binds and lists existing binds.", - "command.config.description": "Saves, reloads, or locates the active client config.", + "command.config.description": "Saves, reloads, or locates the client config; uploads it to a cloud slot or loads a shared code.", "command.coordinates.description": "Shows the current player coordinates.", "command.enemy.description": "Manages the client enemy list.", "command.gps.description": "Sets or clears the Gps compass target.", diff --git a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json index 3d57ed9..7392d77 100644 --- a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -3043,7 +3043,7 @@ "setting.nointeract.only_kill_aura": "Только с KillAura", "command.addons.description": "Показывает, сканирует, включает и отключает аддоны клиента.", "command.bind.description": "Изменяет привязки клавиш модулей и показывает текущие привязки.", - "command.config.description": "Сохраняет, перезагружает или показывает путь к конфигурации клиента.", + "command.config.description": "Сохраняет, перезагружает или показывает путь к конфигурации; загружает её в облачный слот или считывает по коду.", "command.coordinates.description": "Показывает текущие координаты игрока.", "command.enemy.description": "Управляет списком врагов клиента.", "command.gps.description": "Задаёт или сбрасывает цель GPS-компаса.", diff --git a/mod/src/test/java/dev/loki/lovisual/features/platform/config/ConfigCloudUploaderTest.java b/mod/src/test/java/dev/loki/lovisual/features/platform/config/ConfigCloudUploaderTest.java new file mode 100644 index 0000000..a7be95c --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/platform/config/ConfigCloudUploaderTest.java @@ -0,0 +1,41 @@ +package dev.loki.lovisual.features.platform.config; + +import com.google.gson.JsonObject; +import dev.loki.lovisual.config.profile.codec.ConfigProfileEntry; +import dev.loki.lovisual.config.profile.codec.ConfigProfileType; +import dev.loki.lovisual.config.profile.snapshot.ConfigProfileMeta; +import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link ConfigCloudUploader#buildBody} wraps a snapshot in the + * {@code {name, data}} shape configs-service expects on + * {@code PUT /configs/{slot}}, with {@code data} being the codec's + * {@code {meta, entries}} tree. This is the pure half of the upload path, so + * it is tested without a running client or backend. + */ +public final class ConfigCloudUploaderTest { + + @Test + void buildsNameAndDataEnvelope() { + ConfigProfileMeta meta = new ConfigProfileMeta("slot1", "My Build", ConfigProfileType.MODULES, "Tester", 10L, 20L, 1); + ConfigProfileSnapshot snapshot = new ConfigProfileSnapshot(meta, + List.of(new ConfigProfileEntry("module:ChinaHat", "China Hat", Map.of("enabled", true)))); + + JsonObject body = ConfigCloudUploader.buildBody("My Build", snapshot); + + assertEquals("My Build", body.get("name").getAsString()); + JsonObject data = body.getAsJsonObject("data"); + assertEquals("slot1", data.getAsJsonObject("meta").get("id").getAsString()); + assertEquals("My Build", data.getAsJsonObject("meta").get("name").getAsString()); + assertTrue(data.getAsJsonArray("entries").get(0).isJsonObject()); + assertEquals("module:ChinaHat", + data.getAsJsonArray("entries").get(0).getAsJsonObject().get("ownerId").getAsString()); + } +}