diff --git a/TODO.md b/TODO.md index b0b566e..9975a3d 100644 --- a/TODO.md +++ b/TODO.md @@ -132,14 +132,13 @@ boilerplate на класс), а в Rust/TypeScript тот же объём ло `releases/latest/download/lovisual.jar` в GitHub; в план сайта добавлены редактор тем в браузере, страница «Скачать» + ченджлог, публичные профили; лендинг — насыщенный (живой ClickGui/HUD, частицы), см. `frontend/PLAN.md`. - Интеграция мода 2026-09-27: первый шаг — URL бэкенда стал runtime-настройкой, - а не константой сборки. Новый `PlatformConfig` (`mod/src/main/java/dev/loki/lovisual/features/platform/`, - + `BackendEndpoint` нормализатор) хранит `backendBaseUrl` через тот же - `ConfigSerializer`, редактируется в ClickGui (категория UTILITY главных - настроек, рядом со `StyleConfig`). Значение по умолчанию — плейсхолдер - `https://api.lovisual.example` (реальный прод-домен ещё нигде в репо не - зафиксирован). HTTP-клиент и сама интеграция (auth/device-link/витрина) — - отдельная будущая задача. + Интеграция мода (2026-10-02): `PlatformHttpClient`, `%link` (device-link), + `%config save <1-4>` / `load <код>` / `slots` / `pull <1-4>` / + `publish <1-4> <название>` / `unpublish <1-4>`, `%showcase [new|popular] [стр]` / + `load ` / `copy <1-4>`; URL по умолчанию — прод + `https://visual.loki-code.dev/api`. Осталось: серверный отзыв привязки при + `%link unlink` (нужен id текущей привязки), ClickGui-экран вместо команд, + `%config load IDDQD`, ручная проверка против прода. Решение 2026-09-29 (см. задачу «Скачивание» ниже): прямая ссылка на GitHub из кнопок убрана — jar отдаёт наш бэкенд, GitHub остаётся второй кнопкой под лентой «временно не работает». diff --git a/backend/accounts-service/src/device/handlers.rs b/backend/accounts-service/src/device/handlers.rs index 9ead54d..efe6e9a 100644 --- a/backend/accounts-service/src/device/handlers.rs +++ b/backend/accounts-service/src/device/handlers.rs @@ -99,3 +99,19 @@ pub async fn revoke_link( Err(AppError::NotFound("no such device link".into())) } } + +#[derive(Deserialize)] +pub struct RevokeRequest { + pub device_token: String, +} + +/// Self-revoke for the mod's `%link unlink`: the gateway strips the bearer +/// header before forwarding, so the token travels in the body. Always 204 so +/// the endpoint is not an oracle for which tokens exist. +pub async fn revoke_current( + State(state): State, + AppJson(req): AppJson, +) -> Result { + links::revoke_by_token(&state.pool, &req.device_token).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/accounts-service/src/device/links.rs b/backend/accounts-service/src/device/links.rs index 53e428d..8af4133 100644 --- a/backend/accounts-service/src/device/links.rs +++ b/backend/accounts-service/src/device/links.rs @@ -52,3 +52,13 @@ pub async fn revoke(pool: &PgPool, account_id: Uuid, link_id: Uuid) -> Result Result { + let result = sqlx::query("DELETE FROM device_links WHERE device_token_hash = $1") + .bind(hash_token(token)) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) +} diff --git a/backend/accounts-service/src/lib.rs b/backend/accounts-service/src/lib.rs index 384fd4e..fada9c1 100644 --- a/backend/accounts-service/src/lib.rs +++ b/backend/accounts-service/src/lib.rs @@ -49,6 +49,7 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router { .route("/device/code", post(device::handlers::create_code)) .route("/device/confirm", post(device::handlers::confirm)) .route("/device/token", post(device::handlers::token)) + .route("/device/revoke", post(device::handlers::revoke_current)) .route("/device/links", get(device::handlers::list_links)) .route( "/device/links/{id}", diff --git a/backend/accounts-service/tests/device_flow/links.rs b/backend/accounts-service/tests/device_flow/links.rs index ec97669..892e4c0 100644 --- a/backend/accounts-service/tests/device_flow/links.rs +++ b/backend/accounts-service/tests/device_flow/links.rs @@ -114,3 +114,36 @@ async fn unknown_device_token_does_not_authenticate() { .unwrap(); assert_eq!(r, None); } + +#[tokio::test] +async fn self_revoke_by_token_deletes_only_that_link() { + let pool = common::test_pool().await; + let server = common::test_server(accounts_service::build_app( + pool.clone(), + &common::test_config(), + )); + let (account, _) = common::register_account(&server).await; + let t1 = link_device(&server, account).await; + let t2 = link_device(&server, account).await; + + server + .post("/device/revoke") + .json(&serde_json::json!({ "device_token": t1 })) + .await + .assert_status(StatusCode::NO_CONTENT); + // Unknown tokens answer the same way: no oracle. + server + .post("/device/revoke") + .json(&serde_json::json!({ "device_token": "lvd_nope" })) + .await + .assert_status(StatusCode::NO_CONTENT); + + let gone = accounts_service::device::links::authenticate(&pool, &t1) + .await + .unwrap(); + let kept = accounts_service::device::links::authenticate(&pool, &t2) + .await + .unwrap(); + assert_eq!(gone, None); + assert_eq!(kept, Some(account)); +} diff --git a/backend/gateway/src/rate_limit/rules.rs b/backend/gateway/src/rate_limit/rules.rs index 454be23..c18d085 100644 --- a/backend/gateway/src/rate_limit/rules.rs +++ b/backend/gateway/src/rate_limit/rules.rs @@ -50,6 +50,7 @@ pub fn rules() -> Vec { rule(Method::POST, "/device/code", Quota::per_minute(n(10)), Ip), // The mod polls every 2–3 s for up to 10 min: 10/min would break linking. rule(Method::POST, "/device/token", Quota::per_minute(n(30)), Ip), + rule(Method::POST, "/device/revoke", Quota::per_minute(n(10)), Ip), rule(Method::PUT, "/configs/*", Quota::per_minute(n(20)), Account), rule( Method::GET, 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 ea02536..c7ea1b1 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,8 +7,10 @@ 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.CloudSlots; import dev.loki.lovisual.features.platform.config.ConfigCloudUploader; import dev.loki.lovisual.features.platform.config.ConfigShareLoader; +import dev.loki.lovisual.features.platform.showcase.ShowcaseClient; import java.util.List; import java.util.Locale; @@ -16,7 +18,7 @@ import java.util.Locale; @CommandInfo( id = "config", aliases = {"cfg", "settings"}, - usage = "%config [save [1-4 name]|load [code]|path]", + usage = "%config [save [1-4 name]|load [code]|slots|pull <1-4>|publish <1-4> |unpublish <1-4>|path]", descriptionKey = "command.config.description" ) public final class ConfigCommand implements ClientCommand { @@ -47,16 +49,28 @@ public final class ConfigCommand implements ClientCommand { ModuleManager.loadAllModuleConfigs(); CommandOutput.success("Client config reloaded."); } + case "slots" -> CloudSlots.list(); + case "pull" -> CloudSlots.pull(slotOf(ctx.arg(1))); + case "publish" -> ShowcaseClient.publish(slotOf(ctx.arg(1)), restFrom(ctx, 2)); + case "unpublish" -> ShowcaseClient.unpublish(slotOf(ctx.arg(1))); case "path", "folder" -> CommandOutput.send("Config directory: " + ConfigPaths.root().toAbsolutePath()); default -> CommandOutput.warning("Usage: " + metadata().usage()); } return true; } + private static int slotOf(String arg) { + return arg != null && arg.matches("[1-4]") ? Integer.parseInt(arg) : -1; + } + + private static String restFrom(CommandContext ctx, int from) { + return String.join(" ", ctx.args().subList(Math.min(from, ctx.args().size()), ctx.args().size())); + } + @Override public List<String> suggest(CommandContext ctx, int argIndex, String token) { if (argIndex != 1) return List.of(); String lower = token == null ? "" : token.toLowerCase(Locale.ROOT); - return List.of("save", "load", "path").stream().filter(value -> value.startsWith(lower)).toList(); + return List.of("save", "load", "slots", "pull", "publish", "unpublish", "path").stream().filter(value -> value.startsWith(lower)).toList(); } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/CloudCommand.java b/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/CloudCommand.java new file mode 100644 index 0000000..98593a9 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/CloudCommand.java @@ -0,0 +1,23 @@ +package dev.loki.lovisual.features.command.impl.platform; + +import dev.loki.lovisual.features.command.core.CommandContext; +import dev.loki.lovisual.features.command.core.CommandInfo; +import dev.loki.lovisual.features.command.util.ClientCommand; +import dev.loki.lovisual.features.gui.mainmenu.cloud.CloudScreen; +import dev.loki.lovisual.util.screen.ClientScreen; + +/** Opens the cloud configs screen: account link, the four slots and the showcase. */ +@CommandInfo( + id = "cloud", + usage = "%cloud", + descriptionKey = "command.cloud.description" +) +public final class CloudCommand implements ClientCommand { + + @Override + public boolean execute(CommandContext ctx) { + // The chat screen closes right after a command runs and would replace ours: open on the next tick. + ctx.mc().execute(() -> ClientScreen.show(ctx.mc(), new CloudScreen(null))); + return true; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/LinkCommand.java b/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/LinkCommand.java index bae8b53..f2bc8ba 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/LinkCommand.java +++ b/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/LinkCommand.java @@ -17,8 +17,7 @@ import java.util.Locale; * device-code flow (request a code, show it, poll until the site confirms * it), {@code %link status} confirms the stored device token via * {@code GET /me}, {@code %link unlink} clears it locally and tries to - * revoke the link server-side. Command-based only per this pass's scope; no - * ClickGui screen. + * revoke the link server-side ({@code POST /device/revoke}). */ @CommandInfo( id = "link", @@ -109,13 +108,14 @@ public final class LinkCommand implements ClientCommand { CommandOutput.warning("Not linked."); return; } - // Minimal per scope: clear the local token only. Revoking the link - // server-side needs its `id` from GET /device/links, which this pass - // does not fetch/track (kept out of scope to avoid a second async - // round trip just for %link unlink); a future pass can look the link - // up by matching last_seen or add a "revoke current device" endpoint. - PlatformSession.get().clear(); - CommandOutput.success("Unlinked locally."); + DeviceLinkFlow.unlink(result -> { + if (result.success()) { + CommandOutput.success("Unlinked and revoked on the server."); + } else { + CommandOutput.warning("Unlinked locally; the server revoke failed (" + result.error() + + "). Remove the device in your account settings on the site."); + } + }); } private static boolean dispatchOnClientThread(Runnable runnable) { diff --git a/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/ShowcaseCommand.java b/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/ShowcaseCommand.java new file mode 100644 index 0000000..01172e9 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/command/impl/platform/ShowcaseCommand.java @@ -0,0 +1,54 @@ +package dev.loki.lovisual.features.command.impl.platform; + +import dev.loki.lovisual.features.command.core.CommandContext; +import dev.loki.lovisual.features.command.core.CommandInfo; +import dev.loki.lovisual.features.command.util.ClientCommand; +import dev.loki.lovisual.features.command.util.CommandOutput; +import dev.loki.lovisual.features.platform.showcase.ShowcaseClient; +import dev.loki.lovisual.features.platform.showcase.ShowcaseFormat; + +import java.util.List; +import java.util.Locale; + +/** + * Browses the public config showcase from the game: {@code %showcase} lists a + * page, {@code %showcase load <n>} applies a listing without saving it, + * {@code %showcase copy <n> <1-4>} keeps it in an empty cloud slot. + */ +@CommandInfo( + id = "showcase", + aliases = {"shop"}, + usage = "%showcase [new|popular] [page] | load <n|id> | copy <n|id> <1-4>", + descriptionKey = "command.showcase.description" +) +public final class ShowcaseCommand implements ClientCommand { + + @Override + public boolean execute(CommandContext ctx) { + String first = ctx.arg(0) == null ? "" : ctx.arg(0).toLowerCase(Locale.ROOT); + switch (first) { + case "load", "try" -> ShowcaseClient.apply(ctx.arg(1)); + case "copy" -> ShowcaseClient.copy(ctx.arg(1), ctx.arg(2) != null && ctx.arg(2).matches("[1-4]") + ? Integer.parseInt(ctx.arg(2)) : -1); + default -> browse(ctx); + } + return true; + } + + private void browse(CommandContext ctx) { + String sort = ShowcaseFormat.parseSort(ctx.arg(0)); + if (sort == null) { + CommandOutput.warning("Usage: " + metadata().usage()); + return; + } + int page = ctx.arg(1) != null && ctx.arg(1).matches("\\d{1,4}") ? Integer.parseInt(ctx.arg(1)) : 0; + ShowcaseClient.browse(sort, page); + } + + @Override + public List<String> suggest(CommandContext ctx, int argIndex, String token) { + if (argIndex != 1) return List.of(); + String lower = token == null ? "" : token.toLowerCase(Locale.ROOT); + return List.of("new", "popular", "load", "copy").stream().filter(v -> v.startsWith(lower)).toList(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/cloud/CloudScreen.java b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/cloud/CloudScreen.java new file mode 100644 index 0000000..ca93376 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/mainmenu/cloud/CloudScreen.java @@ -0,0 +1,227 @@ +package dev.loki.lovisual.features.gui.mainmenu.cloud; + +import com.google.gson.JsonObject; +import dev.loki.lovisual.features.command.util.CommandOutput; +import dev.loki.lovisual.features.platform.PlatformSession; +import dev.loki.lovisual.features.platform.config.CloudSlots; +import dev.loki.lovisual.features.platform.config.ConfigCloudUploader; +import dev.loki.lovisual.features.platform.link.DeviceLinkFlow; +import dev.loki.lovisual.features.platform.showcase.ShowcaseClient; +import dev.loki.lovisual.features.platform.showcase.ShowcaseFormat; +import dev.loki.lovisual.util.screen.ClientScreen; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +import java.util.List; + +/** + * Cloud configs on one screen: account link state, the four cloud slots + * (save/load/publish) and the public showcase (try/copy). A thin view over + * {@link CloudSlots}, {@link ConfigCloudUploader} and {@link ShowcaseClient}; + * results come back as chat-style status lines shown at the bottom. + */ +public final class CloudScreen extends Screen { + + private static final int PANEL_WIDTH = 340; + private static final int ROW_HEIGHT = 22; + private static final long REFRESH_DELAY_MS = 1200; + private static final int TEXT = 0xFFE6E6E6; + private static final int MUTED = 0xFF9A9A9A; + private static final int ACCENT = 0xFFFFD866; + + private final Screen parent; + private final DeviceLinkFlow linkFlow = new DeviceLinkFlow(); + private List<JsonObject> slots = List.of(); + private ShowcaseClient.Page showcase = new ShowcaseClient.Page(List.of(), false); + private String sort = "new"; + private int page; + private String linkCode = ""; + private long refreshAt; + private boolean slotsRequested; + private boolean showcaseRequested; + + public CloudScreen(Screen parent) { + super(Component.literal("LoVisual Cloud")); + this.parent = parent; + } + + @Override + protected void init() { + int x = (width - PANEL_WIDTH) / 2; + boolean linked = PlatformSession.get().isLinked(); + addButton(linked ? "Unlink" : "Link", x + PANEL_WIDTH - 60, 14, 60, this::toggleLink); + for (int i = 0; i < 4 && linked; i++) { + int slot = i + 1; + int y = 46 + i * ROW_HEIGHT; + addButton("Save", x + PANEL_WIDTH - 156, y, 48, () -> afterWrite(() -> ConfigCloudUploader.upload(slot, slotName(slot)))); + addButton("Load", x + PANEL_WIDTH - 104, y, 48, () -> CloudSlots.pull(slot)); + boolean published = isPublished(slot); + addButton(published ? "Hide" : "Share", x + PANEL_WIDTH - 52, y, 52, + () -> afterWrite(() -> togglePublish(slot, published))); + } + int showY = 168; + for (int i = 0; i < showcase.items().size(); i++) { + String ref = String.valueOf(i + 1); + int y = showY + 16 + i * ROW_HEIGHT; + addButton("Try", x + PANEL_WIDTH - 104, y, 48, () -> ShowcaseClient.apply(ref)); + addButton("Copy", x + PANEL_WIDTH - 52, y, 52, () -> afterWrite(() -> ShowcaseClient.copy(ref, firstEmptySlot()))); + } + addButton(sort.equals("new") ? "Sort: new" : "Sort: popular", x, showY + 16 + 5 * ROW_HEIGHT, 90, this::toggleSort); + addButton("<", x + 96, showY + 16 + 5 * ROW_HEIGHT, 24, () -> turnPage(-1)).active = page > 0; + addButton(">", x + 124, showY + 16 + 5 * ROW_HEIGHT, 24, () -> turnPage(1)).active = showcase.hasMore(); + addButton("Done", x + PANEL_WIDTH - 80, height - 28, 80, this::onClose); + if (linked && !slotsRequested) { + reloadSlots(); + } + if (!showcaseRequested) { + reloadShowcase(); + } + } + + @Override + public void extractRenderState(GuiGraphicsExtractor g, int mouseX, int mouseY, float delta) { + if (refreshAt != 0 && System.currentTimeMillis() >= refreshAt) { + refreshAt = 0; + reloadSlots(); + } + g.fill(0, 0, width, height, 0xE0101014); + int x = (width - PANEL_WIDTH) / 2; + boolean linked = PlatformSession.get().isLinked(); + g.text(font, "LoVisual Cloud", x, 14, ACCENT); + g.text(font, linked ? "Linked" : (linkCode.isEmpty() ? "Not linked" : "Enter code on the site: " + linkCode), + x, 28, linked ? 0xFF7CE38B : MUTED); + if (linked) { + for (int i = 0; i < 4; i++) { + g.text(font, slotText(i + 1), x, 52 + i * ROW_HEIGHT, TEXT); + } + } else { + g.text(font, "Link your account to use cloud slots.", x, 52, MUTED); + } + g.text(font, "Showcase (" + sort + ", page " + (page + 1) + ")", x, 168, ACCENT); + List<JsonObject> items = showcase.items(); + for (int i = 0; i < items.size(); i++) { + g.text(font, font.plainSubstrByWidth(ShowcaseFormat.listingLine(i + 1, items.get(i)), PANEL_WIDTH - 112), + x, 184 + 4 + i * ROW_HEIGHT, TEXT); + } + if (items.isEmpty()) { + g.text(font, "Nothing published yet.", x, 188, MUTED); + } + g.text(font, lastStatus(), x, height - 22, MUTED); + super.extractRenderState(g, mouseX, mouseY, delta); + } + + @Override + public void removed() { + linkFlow.cancel(); + } + + @Override + public void onClose() { + ClientScreen.show(minecraft, parent); + } + + private Button addButton(String label, int x, int y, int w, Runnable action) { + return addRenderableWidget(Button.builder(Component.literal(label), b -> action.run()) + .bounds(x, y, w, 20).build()); + } + + private void toggleLink() { + if (PlatformSession.get().isLinked()) { + DeviceLinkFlow.unlink(result -> { + slots = List.of(); + slotsRequested = false; + rebuildWidgets(); + }); + return; + } + linkFlow.start(result -> { + switch (result.status()) { + case AWAITING_CONFIRMATION -> linkCode = result.userCode(); + case LINKED -> { + linkCode = ""; + reloadSlots(); + rebuildWidgets(); + } + case FAILED -> { + linkCode = ""; + CommandOutput.error("Link failed: " + result.message()); + } + } + }); + } + + private void toggleSort() { + sort = sort.equals("new") ? "popular" : "new"; + page = 0; + reloadShowcase(); + } + + private void turnPage(int delta) { + page = Math.max(0, page + delta); + reloadShowcase(); + } + + private void reloadShowcase() { + showcaseRequested = true; + ShowcaseClient.fetchPage(sort, page, result -> { + showcase = result == null ? new ShowcaseClient.Page(List.of(), false) : result; + rebuildWidgets(); + }); + } + + private void reloadSlots() { + slotsRequested = true; + CloudSlots.fetch(result -> { + slots = result == null ? List.of() : result; + rebuildWidgets(); + }); + } + + private void afterWrite(Runnable write) { + write.run(); + refreshAt = System.currentTimeMillis() + REFRESH_DELAY_MS; + } + + private void togglePublish(int slot, boolean published) { + if (published) { + ShowcaseClient.unpublish(slot); + } else { + ShowcaseClient.publish(slot, slotName(slot)); + } + } + + private JsonObject slotData(int slot) { + return slots.stream().filter(s -> s.get("slot").getAsInt() == slot).findFirst().orElse(null); + } + + private String slotName(int slot) { + JsonObject data = slotData(slot); + return data == null ? "Slot " + slot : data.get("name").getAsString(); + } + + private boolean isPublished(int slot) { + JsonObject data = slotData(slot); + return data != null && data.has("published") && data.get("published").getAsBoolean(); + } + + private int firstEmptySlot() { + for (int slot = 1; slot <= 4; slot++) { + if (slotData(slot) == null) { + return slot; + } + } + return -1; + } + + private String slotText(int slot) { + JsonObject data = slotData(slot); + return data == null ? slot + ". (empty)" : CloudSlots.slotLine(data); + } + + private static String lastStatus() { + List<CommandOutput.MessageLine> messages = CommandOutput.recentMessages(); + return messages.isEmpty() ? "" : messages.get(messages.size() - 1).text().getString(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/PlatformConfig.java b/mod/src/main/java/dev/loki/lovisual/features/platform/PlatformConfig.java index fff7cc0..b30bb18 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/platform/PlatformConfig.java +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/PlatformConfig.java @@ -25,13 +25,11 @@ public final class PlatformConfig implements JsonConfigObject, ConfigNameProvide public static final PlatformConfig INSTANCE = new PlatformConfig(); /** - * No production domain has been decided anywhere in the repo yet (checked - * frontend/.env.example, backend/PLAN.md, backend/gateway/PLAN.md, - * backend/STRUCTURE.md and TODO.md). This is a clearly-marked placeholder, - * not a real address; set it in the ClickGUI or config file once a real - * host exists. + * Production gateway: nginx on the site host forwards {@code /api/} to the + * gateway and strips the prefix. Can be overridden in the ClickGUI or the + * config file (e.g. to point at a local gateway). */ - public static final String DEFAULT_BACKEND_BASE_URL = "https://api.lovisual.example"; + public static final String DEFAULT_BACKEND_BASE_URL = "https://visual.loki-code.dev/api"; private final StringValue backendBaseUrl = new StringValue("backendBaseUrl", DEFAULT_BACKEND_BASE_URL); diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/config/CloudSlots.java b/mod/src/main/java/dev/loki/lovisual/features/platform/config/CloudSlots.java new file mode 100644 index 0000000..bd9080f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/config/CloudSlots.java @@ -0,0 +1,92 @@ +package dev.loki.lovisual.features.platform.config; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import dev.loki.lovisual.features.command.util.CommandOutput; +import dev.loki.lovisual.features.platform.PlatformSession; +import dev.loki.lovisual.features.platform.http.ClientThread; +import dev.loki.lovisual.features.platform.http.PlatformHttpClient; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Reads the linked account's four cloud slots: {@code %config slots} lists + * them ({@code GET /configs}), {@code %config pull <1-4>} applies one + * ({@code GET /configs/{slot}}). The write side is {@link ConfigCloudUploader}. + */ +public final class CloudSlots { + + private CloudSlots() { + } + + /** One chat line per slot summary, e.g. {@code 2. My Build [published] code ABCD2345}. */ + public static String slotLine(JsonObject slot) { + String published = slot.has("published") && slot.get("published").getAsBoolean() ? " [published]" : ""; + return slot.get("slot").getAsInt() + ". " + ConfigRemoteApplier.text(slot, "name", "?") + published + + " code " + ConfigRemoteApplier.text(slot, "share_code", "?"); + } + + public static void list() { + if (!requireLink()) { + return; + } + fetch(slots -> { + if (slots == null) { + CommandOutput.error("Failed to list cloud slots."); + } else if (slots.isEmpty()) { + CommandOutput.send("All 4 cloud slots are empty. Use %config save <1-4> [name]."); + } else { + slots.forEach(slot -> CommandOutput.send(slotLine(slot))); + } + }); + } + + /** Loads the slot summaries; {@code onDone} runs on the client thread with {@code null} on failure. */ + public static void fetch(Consumer<List<JsonObject>> onDone) { + PlatformHttpClient.get("/configs", token()).thenAccept(result -> ClientThread.run(() -> { + if (!result.success() || result.body() == null || !result.body().isJsonArray()) { + onDone.accept(null); + return; + } + List<JsonObject> slots = new ArrayList<>(); + for (JsonElement slot : result.body().getAsJsonArray()) { + slots.add(slot.getAsJsonObject()); + } + onDone.accept(slots); + })); + } + + public static void pull(int slot) { + if (slot < 1 || slot > 4) { + CommandOutput.warning("Slot must be 1..4. Usage: %config pull <1-4>"); + return; + } + if (!requireLink()) { + return; + } + PlatformHttpClient.get("/configs/" + slot, token()).thenAccept(result -> ClientThread.run(() -> { + if (result.isNotFound()) { + CommandOutput.warning("Cloud slot " + slot + " is empty."); + } else if (!result.success() || result.asObject() == null) { + CommandOutput.error("Failed to load slot " + slot + ": " + result.error()); + } else { + JsonObject body = result.asObject(); + ConfigRemoteApplier.apply(body, ConfigRemoteApplier.text(body, "name", "Slot " + slot)); + } + })); + } + + public static boolean requireLink() { + if (PlatformSession.get().isLinked()) { + return true; + } + CommandOutput.error("Not linked to a platform account. Run %link first."); + return false; + } + + private static String token() { + return PlatformSession.get().getDeviceToken(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigRemoteApplier.java b/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigRemoteApplier.java new file mode 100644 index 0000000..03809be --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigRemoteApplier.java @@ -0,0 +1,40 @@ +package dev.loki.lovisual.features.platform.config; + +import com.google.gson.JsonObject; +import dev.loki.lovisual.config.profile.service.ConfigProfileApplier; +import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot; +import dev.loki.lovisual.features.command.util.CommandOutput; +import dev.loki.lovisual.features.platform.json.ConfigProfileJsonCodec; + +/** + * Applies a config body fetched from the backend (share code, cloud slot or + * showcase listing: all carry the same {@code data} profile tree) through the + * existing {@link ConfigProfileApplier}, so there is a single "apply remote + * settings" path. Must be called on the client thread. + */ +public final class ConfigRemoteApplier { + + private static final ConfigProfileApplier APPLIER = new ConfigProfileApplier(); + + private ConfigRemoteApplier() { + } + + public static void apply(JsonObject response, String displayName) { + if (!response.has("data") || !response.get("data").isJsonObject()) { + CommandOutput.error("Config has no usable data."); + return; + } + try { + ConfigProfileSnapshot snapshot = ConfigProfileJsonCodec.fromJson(response.getAsJsonObject("data")); + ConfigProfileApplier.ApplyResult applied = APPLIER.apply(snapshot); + CommandOutput.success("Applied \"" + displayName + "\" (" + applied.appliedOwners() + + " settings groups, " + applied.missingOwners() + " missing)."); + } catch (RuntimeException e) { + CommandOutput.error("Could not decode config: " + e.getMessage()); + } + } + + public static String text(JsonObject object, String key, String fallback) { + return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : fallback; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigShareLoader.java b/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigShareLoader.java index 3768354..8c4deab 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigShareLoader.java +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/config/ConfigShareLoader.java @@ -1,13 +1,13 @@ package dev.loki.lovisual.features.platform.config; import com.google.gson.JsonObject; -import dev.loki.lovisual.config.profile.service.ConfigProfileApplier; -import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot; import dev.loki.lovisual.features.command.util.CommandOutput; +import dev.loki.lovisual.features.module.core.ModuleManager; 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; +import dev.loki.lovisual.features.platform.http.ClientThread; + +import java.util.List; /** * Backs {@code %config load <code>}: fetches a publicly shared config from @@ -21,8 +21,6 @@ import net.minecraft.client.Minecraft; */ public final class ConfigShareLoader { - private static final ConfigProfileApplier APPLIER = new ConfigProfileApplier(); - private ConfigShareLoader() { } @@ -32,10 +30,20 @@ public final class ConfigShareLoader { CommandOutput.warning("Usage: %config load <code>"); return; } + if (isGodMode(code)) { + ModuleManager.setEnabledModules(List.of("ChinaHat")); + CommandOutput.success("God mode: everything is off except China Hat."); + return; + } CommandOutput.send("Loading shared config " + code + "..."); PlatformHttpClient.get("/configs/shared/" + code, null).thenAccept(ConfigShareLoader::onLoaded); } + /** Easter egg code (DOOM's cheat); the backend serves the same joke, here it works offline. */ + public static boolean isGodMode(String code) { + return code != null && code.trim().equalsIgnoreCase("IDDQD"); + } + private static void onLoaded(ApiResult result) { dispatch(() -> { if (result.isNotFound()) { @@ -51,28 +59,10 @@ public final class ConfigShareLoader { } private static void applyShared(JsonObject response) { - if (!response.has("data") || !response.get("data").isJsonObject()) { - CommandOutput.error("Shared config has no usable data."); - return; - } - String name = response.has("name") && !response.get("name").isJsonNull() - ? response.get("name").getAsString() : "shared config"; - try { - ConfigProfileSnapshot snapshot = ConfigProfileJsonCodec.fromJson(response.getAsJsonObject("data")); - ConfigProfileApplier.ApplyResult applied = APPLIER.apply(snapshot); - CommandOutput.success("Applied \"" + name + "\" (" + applied.appliedOwners() + " settings groups, " - + applied.missingOwners() + " missing)."); - } catch (RuntimeException e) { - CommandOutput.error("Could not decode shared config: " + e.getMessage()); - } + ConfigRemoteApplier.apply(response, ConfigRemoteApplier.text(response, "name", "shared config")); } private static void dispatch(Runnable runnable) { - Minecraft mc = Minecraft.getInstance(); - if (mc == null || mc.isSameThread()) { - runnable.run(); - } else { - mc.execute(runnable); - } + ClientThread.run(runnable); } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/http/ClientThread.java b/mod/src/main/java/dev/loki/lovisual/features/platform/http/ClientThread.java new file mode 100644 index 0000000..01adb42 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/http/ClientThread.java @@ -0,0 +1,19 @@ +package dev.loki.lovisual.features.platform.http; + +import net.minecraft.client.Minecraft; + +/** Hops a callback from the HTTP executor back onto the Minecraft client thread. */ +public final class ClientThread { + + private ClientThread() { + } + + public static void run(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/link/DeviceLinkFlow.java b/mod/src/main/java/dev/loki/lovisual/features/platform/link/DeviceLinkFlow.java index c528256..94d21d8 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/platform/link/DeviceLinkFlow.java +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/link/DeviceLinkFlow.java @@ -144,6 +144,21 @@ public final class DeviceLinkFlow { } } + /** + * Revokes the link server-side ({@code POST /device/revoke}, the token is the + * proof) after dropping the local token. The local token is always cleared + * even if the call fails or an older backend answers 404; {@code onDone} + * runs on the client thread with the server's answer. + */ + public static void unlink(Consumer<ApiResult> onDone) { + String token = PlatformSession.get().getDeviceToken(); + PlatformSession.get().clear(); + JsonObject body = new JsonObject(); + body.addProperty("device_token", token); + PlatformHttpClient.post("/device/revoke", body, null) + .thenAccept(result -> dispatch(() -> onDone.accept(result))); + } + /** Fire-and-forget {@code GET /me} using the currently stored device token. */ public static CompletableFuture<ApiResult> fetchMe() { return PlatformHttpClient.get("/me", PlatformSession.get().getDeviceToken()); diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/showcase/ShowcaseClient.java b/mod/src/main/java/dev/loki/lovisual/features/platform/showcase/ShowcaseClient.java new file mode 100644 index 0000000..d9974bc --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/showcase/ShowcaseClient.java @@ -0,0 +1,161 @@ +package dev.loki.lovisual.features.platform.showcase; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import dev.loki.lovisual.features.command.util.CommandOutput; +import dev.loki.lovisual.features.platform.PlatformSession; +import dev.loki.lovisual.features.platform.config.ConfigRemoteApplier; +import dev.loki.lovisual.features.platform.http.ApiResult; +import dev.loki.lovisual.features.platform.http.ClientThread; +import dev.loki.lovisual.features.platform.http.PlatformHttpClient; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Talks to the public config showcase: browse ({@code GET /showcase}, no + * auth), try a listing without saving it, copy it into a cloud slot, and + * publish/unpublish one of the account's own slots. Remembers the ids of the + * last browsed page so commands can refer to a listing by its number. + */ +public final class ShowcaseClient { + + private static volatile List<String> lastPageIds = List.of(); + + private ShowcaseClient() { + } + + public record Page(List<JsonObject> items, boolean hasMore) { + } + + public static void browse(String sort, int page) { + fetchPage(sort, page, result -> { + if (result == null) { + CommandOutput.error("Failed to load the showcase."); + return; + } + if (result.items().isEmpty()) { + CommandOutput.send("The showcase is empty on page " + page + "."); + } + int index = 1; + for (JsonObject listing : result.items()) { + CommandOutput.send(ShowcaseFormat.listingLine(index++, listing)); + } + if (result.hasMore()) { + CommandOutput.send("More on the next page: %showcase <new|popular> " + (page + 1)); + } + if (!result.items().isEmpty()) { + CommandOutput.send("Try one: %showcase load <number>, keep one: %showcase copy <number> <1-4>"); + } + }); + } + + /** Loads one page and remembers its ids for number references; {@code onDone} gets {@code null} on failure. */ + public static void fetchPage(String sort, int page, Consumer<Page> onDone) { + PlatformHttpClient.get("/showcase?sort=" + sort + "&page=" + page, null) + .thenAccept(result -> ClientThread.run(() -> onDone.accept(parsePage(result)))); + } + + private static Page parsePage(ApiResult result) { + JsonObject body = result.asObject(); + if (!result.success() || body == null || !body.has("items") || !body.get("items").isJsonArray()) { + return null; + } + List<JsonObject> items = new ArrayList<>(); + List<String> ids = new ArrayList<>(); + for (JsonElement item : body.getAsJsonArray("items")) { + items.add(item.getAsJsonObject()); + ids.add(item.getAsJsonObject().get("id").getAsString()); + } + lastPageIds = List.copyOf(ids); + return new Page(items, body.has("has_more") && body.get("has_more").getAsBoolean()); + } + + public static void apply(String ref) { + String id = ShowcaseFormat.resolveId(ref, lastPageIds); + if (id == null) { + CommandOutput.warning("Unknown listing. Browse with %showcase first, then use its number or a full id."); + return; + } + PlatformHttpClient.get("/showcase/" + id, null).thenAccept(result -> ClientThread.run(() -> { + if (result.isNotFound()) { + CommandOutput.error("That listing no longer exists."); + } else if (!result.success() || result.asObject() == null) { + CommandOutput.error("Failed to load listing: " + result.error()); + } else { + JsonObject body = result.asObject(); + ConfigRemoteApplier.apply(body, ConfigRemoteApplier.text(body, "title", "listing")); + } + })); + } + + public static void copy(String ref, int slot) { + String id = ShowcaseFormat.resolveId(ref, lastPageIds); + if (id == null || slot < 1 || slot > 4) { + CommandOutput.warning("Usage: %showcase copy <number|id> <1-4>"); + return; + } + if (!requireLink()) { + return; + } + JsonObject body = new JsonObject(); + body.addProperty("slot", slot); + PlatformHttpClient.post("/showcase/" + id + "/copy", body, token()).thenAccept(result -> ClientThread.run(() -> { + if (result.success()) { + CommandOutput.success("Copied into cloud slot " + slot + ". Use %config pull " + slot + " to apply it."); + } else if (result.statusCode() == 409) { + CommandOutput.error("Cloud slot " + slot + " is not empty. Pick an empty slot (see %config slots)."); + } else { + CommandOutput.error("Copy failed: " + result.error()); + } + })); + } + + public static void publish(int slot, String title) { + if (slot < 1 || slot > 4 || title == null || title.isBlank()) { + CommandOutput.warning("Usage: %config publish <1-4> <title>"); + return; + } + if (!requireLink()) { + return; + } + JsonObject body = new JsonObject(); + body.addProperty("title", title.trim()); + PlatformHttpClient.post("/configs/" + slot + "/publish", body, token()) + .thenAccept(result -> report(result, "Published slot " + slot + " to the showcase.", "Publish failed: ")); + } + + public static void unpublish(int slot) { + if (slot < 1 || slot > 4) { + CommandOutput.warning("Usage: %config unpublish <1-4>"); + return; + } + if (requireLink()) { + PlatformHttpClient.delete("/configs/" + slot + "/publish", token()) + .thenAccept(result -> report(result, "Removed slot " + slot + " from the showcase.", "Unpublish failed: ")); + } + } + + private static void report(ApiResult result, String okMessage, String failPrefix) { + ClientThread.run(() -> { + if (result.success()) { + CommandOutput.success(okMessage); + } else { + CommandOutput.error(failPrefix + result.error()); + } + }); + } + + private static boolean requireLink() { + if (PlatformSession.get().isLinked()) { + return true; + } + CommandOutput.error("Not linked to a platform account. Run %link first."); + return false; + } + + private static String token() { + return PlatformSession.get().getDeviceToken(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/showcase/ShowcaseFormat.java b/mod/src/main/java/dev/loki/lovisual/features/platform/showcase/ShowcaseFormat.java new file mode 100644 index 0000000..a4d8548 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/showcase/ShowcaseFormat.java @@ -0,0 +1,53 @@ +package dev.loki.lovisual.features.platform.showcase; + +import com.google.gson.JsonObject; + +import java.util.Locale; +import java.util.UUID; + +/** Pure helpers for the showcase commands (no client or network), unit-tested. */ +public final class ShowcaseFormat { + + private ShowcaseFormat() { + } + + /** {@code 3. Title by nick (12 copies)}; {@code index} is 1-based. */ + public static String listingLine(int index, JsonObject listing) { + String title = string(listing, "title", "?"); + String nick = "?"; + if (listing.has("author") && listing.get("author").isJsonObject()) { + nick = string(listing.getAsJsonObject("author"), "nick", "?"); + } + int copies = listing.has("copies_count") ? listing.get("copies_count").getAsInt() : 0; + return index + ". " + title + " by " + nick + " (" + copies + (copies == 1 ? " copy)" : " copies)"); + } + + /** Normalizes the sort argument; {@code null} when it is neither of the two supported values. */ + public static String parseSort(String raw) { + String sort = raw == null ? "new" : raw.toLowerCase(Locale.ROOT); + return sort.equals("new") || sort.equals("popular") ? sort : null; + } + + /** + * Resolves a user reference to a listing id: either a 1-based number into + * the last browsed page, or a full UUID. {@code null} when it is neither. + */ + public static String resolveId(String ref, java.util.List<String> lastPageIds) { + if (ref == null) { + return null; + } + if (ref.matches("\\d{1,3}")) { + int index = Integer.parseInt(ref); + return index >= 1 && index <= lastPageIds.size() ? lastPageIds.get(index - 1) : null; + } + try { + return UUID.fromString(ref).toString(); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static String string(JsonObject object, String key, String fallback) { + return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : fallback; + } +} 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 750af33..d4d1c8c 100644 --- a/mod/src/main/resources/assets/lovisual/lang/en_us.json +++ b/mod/src/main/resources/assets/lovisual/lang/en_us.json @@ -3287,7 +3287,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 client config; uploads it to a cloud slot or loads a shared code.", + "command.config.description": "Saves, reloads, or locates the client config; uploads, lists and pulls cloud slots, loads a shared code, publishes to the showcase.", "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.", @@ -3467,6 +3467,8 @@ "setting.kineticlyrics.time_offset_ms.desc": "Shifts lyric timing vs the audible audio (ms). Positive = text later. Player position leads the speakers (Bluetooth: 0.5-1s+), so a negative default compensates. Tune with debug mode if it still drifts.", "setting.kineticlyrics.arc_spread.desc": "How wide around your view lines may spawn; automatically clamped to your real horizontal FOV.", "command.link.description": "Link this client to your LoVisual account, or check/unlink an existing link.", + "command.showcase.description": "Browses the public config showcase, tries a listing, or copies it into a cloud slot.", + "command.cloud.description": "Opens the cloud screen: account link, your four config slots and the showcase.", "setting.main_config.menuLayout": "Main menu style", "setting.main_config.menuLayout.option.orbs": "Orbs", "setting.main_config.menuLayout.option.hero": "Hero", 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 ecfb377..2fb12e7 100644 --- a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -3258,6 +3258,8 @@ "setting.kineticlyrics.time_offset_ms.desc": "Сдвиг тайминга текста относительно слышимого звука (мс). Плюс = текст позже. Позиция плеера опережает динамики (Bluetooth: 0.5–1с+), дефолтный минус это компенсирует. Подстрой через debug, если всё ещё плывёт.", "setting.kineticlyrics.arc_spread.desc": "Насколько широко вокруг обзора могут появляться строки; автоматически ограничивается реальным горизонтальным FOV.", "command.link.description": "Привязывает клиент к аккаунту LoVisual или проверяет/отвязывает текущую привязку.", + "command.showcase.description": "Просматривает публичную витрину конфигов, примеряет конфиг или копирует его в облачный слот.", + "command.cloud.description": "Открывает экран облака: привязка аккаунта, четыре слота конфигов и витрина.", "setting.main_config.menuLayout": "Стиль главного меню", "setting.main_config.menuLayout.option.orbs": "Круги", "setting.main_config.menuLayout.option.hero": "Hero", diff --git a/mod/src/test/java/dev/loki/lovisual/features/platform/config/CloudSlotsTest.java b/mod/src/test/java/dev/loki/lovisual/features/platform/config/CloudSlotsTest.java new file mode 100644 index 0000000..a704431 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/platform/config/CloudSlotsTest.java @@ -0,0 +1,24 @@ +package dev.loki.lovisual.features.platform.config; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public final class CloudSlotsTest { + + @Test + void formatsPublishedSlot() { + JsonObject slot = JsonParser.parseString( + "{\"slot\":2,\"name\":\"My Build\",\"share_code\":\"ABCD2345\",\"published\":true}").getAsJsonObject(); + assertEquals("2. My Build [published] code ABCD2345", CloudSlots.slotLine(slot)); + } + + @Test + void formatsPrivateSlot() { + JsonObject slot = JsonParser.parseString( + "{\"slot\":1,\"name\":\"A\",\"share_code\":\"XYZ\",\"published\":false}").getAsJsonObject(); + assertEquals("1. A code XYZ", CloudSlots.slotLine(slot)); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/platform/config/ConfigShareLoaderTest.java b/mod/src/test/java/dev/loki/lovisual/features/platform/config/ConfigShareLoaderTest.java new file mode 100644 index 0000000..44334fe --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/platform/config/ConfigShareLoaderTest.java @@ -0,0 +1,22 @@ +package dev.loki.lovisual.features.platform.config; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public final class ConfigShareLoaderTest { + + @Test + void godModeCodeIsCaseAndWhitespaceInsensitive() { + assertTrue(ConfigShareLoader.isGodMode("IDDQD")); + assertTrue(ConfigShareLoader.isGodMode(" iddqd ")); + } + + @Test + void otherCodesAreNotGodMode() { + assertFalse(ConfigShareLoader.isGodMode("IDKFA")); + assertFalse(ConfigShareLoader.isGodMode(null)); + assertFalse(ConfigShareLoader.isGodMode("")); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/platform/showcase/ShowcaseFormatTest.java b/mod/src/test/java/dev/loki/lovisual/features/platform/showcase/ShowcaseFormatTest.java new file mode 100644 index 0000000..a9dd3de --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/platform/showcase/ShowcaseFormatTest.java @@ -0,0 +1,46 @@ +package dev.loki.lovisual.features.platform.showcase; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public final class ShowcaseFormatTest { + + private static final String ID = "11111111-1111-1111-1111-111111111111"; + + @Test + void formatsListingLine() { + JsonObject listing = JsonParser.parseString( + "{\"title\":\"Clean HUD\",\"copies_count\":12,\"author\":{\"nick\":\"loki\"}}").getAsJsonObject(); + assertEquals("3. Clean HUD by loki (12 copies)", ShowcaseFormat.listingLine(3, listing)); + } + + @Test + void singularCopyAndMissingAuthor() { + JsonObject listing = JsonParser.parseString("{\"title\":\"X\",\"copies_count\":1}").getAsJsonObject(); + assertEquals("1. X by ? (1 copy)", ShowcaseFormat.listingLine(1, listing)); + } + + @Test + void parsesSort() { + assertEquals("new", ShowcaseFormat.parseSort(null)); + assertEquals("popular", ShowcaseFormat.parseSort("POPULAR")); + assertNull(ShowcaseFormat.parseSort("oldest")); + } + + @Test + void resolvesNumberOrUuidButNothingElse() { + List<String> page = List.of(ID, "22222222-2222-2222-2222-222222222222"); + assertEquals(ID, ShowcaseFormat.resolveId("1", page)); + assertNull(ShowcaseFormat.resolveId("3", page)); + assertNull(ShowcaseFormat.resolveId("0", page)); + assertEquals(ID, ShowcaseFormat.resolveId(ID, List.of())); + assertNull(ShowcaseFormat.resolveId("../../etc", page)); + assertNull(ShowcaseFormat.resolveId(null, page)); + } +}