Added info uploader tool tip, moderators can delete shared cosmetics/opped users, added a black fill to preview boxes
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
package com.razz.dfashion.client;
|
||||||
|
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.RequestCosmeticInfo;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side cache of uploader metadata for shared cosmetics. Fetched lazily from the
|
||||||
|
* server via {@link RequestCosmeticInfo} the first time the UI asks about a hash; the
|
||||||
|
* server's {@link com.razz.dfashion.cosmetic.share.packet.CosmeticInfoResponse} lands in
|
||||||
|
* {@link #onResponse} and the cached value is then read synchronously by the tooltip.
|
||||||
|
*
|
||||||
|
* <p>Entries are process-lifetime; a new server world/login starts empty. This is fine
|
||||||
|
* for a UI hint — nothing functional depends on freshness and OP-initiated deletes on
|
||||||
|
* the server can't produce a mismatch the user would notice.
|
||||||
|
*/
|
||||||
|
public final class ClientCosmeticInfoCache {
|
||||||
|
|
||||||
|
public record Info(String uploaderName, long uploadedAt) {
|
||||||
|
public boolean isKnown() {
|
||||||
|
return !uploaderName.isEmpty() || uploadedAt != 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<String, Info> CACHE = new ConcurrentHashMap<>();
|
||||||
|
private static final java.util.Set<String> PENDING = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
private ClientCosmeticInfoCache() {}
|
||||||
|
|
||||||
|
/** Known info for the hash, or {@code null} if not yet received. */
|
||||||
|
public static Info get(String hash) {
|
||||||
|
return CACHE.get(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a fetch if we've never requested this hash. Idempotent — repeat calls while
|
||||||
|
* a request is pending are coalesced. Once a response lands the cache stays populated
|
||||||
|
* for the rest of the session.
|
||||||
|
*/
|
||||||
|
public static void requestIfMissing(String hash) {
|
||||||
|
if (hash == null || hash.isEmpty()) return;
|
||||||
|
if (CACHE.containsKey(hash)) return;
|
||||||
|
if (!PENDING.add(hash)) return;
|
||||||
|
ClientSharedCosmeticCache.sendToServer(new RequestCosmeticInfo(hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void onResponse(String hash, String uploaderName, long uploadedAt) {
|
||||||
|
CACHE.put(hash, new Info(uploaderName == null ? "" : uploaderName, uploadedAt));
|
||||||
|
PENDING.remove(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop a hash's info (e.g. after an OP-delete) so the UI won't show stale data. */
|
||||||
|
public static void forget(String hash) {
|
||||||
|
CACHE.remove(hash);
|
||||||
|
PENDING.remove(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,7 +285,13 @@ public final class ClientSharedCosmeticCache {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop a cached shared cosmetic from this client — releases its texture, forgets the
|
* Drop a cached shared cosmetic from this client — releases its texture, forgets the
|
||||||
* baked entry, removes the local cache file. The server's copy is untouched.
|
* baked entry, removes the local cache file, and clears any in-flight / pending
|
||||||
|
* request state. The server's copy is untouched.
|
||||||
|
*
|
||||||
|
* <p>Clearing {@code IN_FLIGHT} + {@code PENDING_REQUESTS} matters for the delete →
|
||||||
|
* re-upload round trip: any request sent before the server's delete landed gets no
|
||||||
|
* response, so without this cleanup the short-circuit in {@link #requestIfMissing}
|
||||||
|
* would prevent re-downloading the blob after a re-upload of identical content.
|
||||||
*/
|
*/
|
||||||
public static boolean delete(String hash) {
|
public static boolean delete(String hash) {
|
||||||
if (!SharedCosmeticCache.isValidHash(hash)) return false;
|
if (!SharedCosmeticCache.isValidHash(hash)) return false;
|
||||||
@@ -298,6 +304,8 @@ public final class ClientSharedCosmeticCache {
|
|||||||
DecoFashion.LOGGER.warn("shared cosmetic {}: texture release failed ({})", hash, t.getMessage());
|
DecoFashion.LOGGER.warn("shared cosmetic {}: texture release failed ({})", hash, t.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
IN_FLIGHT.remove(hash);
|
||||||
|
PENDING_REQUESTS.remove(hash);
|
||||||
try {
|
try {
|
||||||
if (Files.deleteIfExists(fileFor(hash))) changed = true;
|
if (Files.deleteIfExists(fileFor(hash))) changed = true;
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.razz.dfashion.client.screen;
|
package com.razz.dfashion.client.screen;
|
||||||
|
|
||||||
import com.razz.dfashion.block.ClosetBlockEntity;
|
import com.razz.dfashion.block.ClosetBlockEntity;
|
||||||
|
import com.razz.dfashion.client.ClientCosmeticInfoCache;
|
||||||
import com.razz.dfashion.client.ClientSharedCosmeticCache;
|
import com.razz.dfashion.client.ClientSharedCosmeticCache;
|
||||||
import com.razz.dfashion.client.ClientSharedCosmeticUploader;
|
import com.razz.dfashion.client.ClientSharedCosmeticUploader;
|
||||||
import com.razz.dfashion.client.ClientSkinCache;
|
import com.razz.dfashion.client.ClientSkinCache;
|
||||||
@@ -35,6 +36,7 @@ import net.minecraft.client.gui.GuiGraphicsExtractor;
|
|||||||
import net.minecraft.client.gui.components.Button;
|
import net.minecraft.client.gui.components.Button;
|
||||||
import net.minecraft.client.gui.components.EditBox;
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
import net.minecraft.client.gui.components.StringWidget;
|
import net.minecraft.client.gui.components.StringWidget;
|
||||||
|
import net.minecraft.client.gui.components.Tooltip;
|
||||||
import net.minecraft.client.gui.screens.Screen;
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
import net.minecraft.client.player.LocalPlayer;
|
import net.minecraft.client.player.LocalPlayer;
|
||||||
import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
|
import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
|
||||||
@@ -63,7 +65,9 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.InvalidPathException;
|
import java.nio.file.InvalidPathException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -109,6 +113,7 @@ public class ClosetScreen extends Screen {
|
|||||||
private static final int COSMETIC_BORDER_COLOR = 0xFFFFFFFF; // normal frame around each preview
|
private static final int COSMETIC_BORDER_COLOR = 0xFFFFFFFF; // normal frame around each preview
|
||||||
private static final int COSMETIC_BORDER_EQUIPPED_COLOR = 0xFF55FF55; // green frame when this item is currently worn
|
private static final int COSMETIC_BORDER_EQUIPPED_COLOR = 0xFF55FF55; // green frame when this item is currently worn
|
||||||
private static final int COSMETIC_BORDER_SHARED_COLOR = 0xFFFFB060; // amber frame — entry came from the user's shared library
|
private static final int COSMETIC_BORDER_SHARED_COLOR = 0xFFFFB060; // amber frame — entry came from the user's shared library
|
||||||
|
private static final int COSMETIC_BACKDROP_COLOR = 0xFF000000; // opaque black fill behind the 3D mini-player so the world doesn't poke through
|
||||||
private static final float COSMETIC_PREVIEW_SIZE = 11f; // entity render scale — small enough to fit cosmetics that extend the silhouette
|
private static final float COSMETIC_PREVIEW_SIZE = 11f; // entity render scale — small enough to fit cosmetics that extend the silhouette
|
||||||
private static final float COSMETIC_PREVIEW_YAW = -20f; // degrees — angled for better cosmetic visibility
|
private static final float COSMETIC_PREVIEW_YAW = -20f; // degrees — angled for better cosmetic visibility
|
||||||
|
|
||||||
@@ -173,6 +178,13 @@ public class ClosetScreen extends Screen {
|
|||||||
private final Map<Button, String> sharedButtonHashes = new HashMap<>();
|
private final Map<Button, String> sharedButtonHashes = new HashMap<>();
|
||||||
private int lastKnownSharedCount = -1;
|
private int lastKnownSharedCount = -1;
|
||||||
|
|
||||||
|
/** Info-button hash map so the tick handler can refresh tooltips as upload metadata
|
||||||
|
* arrives from the server. Cleared in {@link #rebuildCosmeticRow} alongside the
|
||||||
|
* other per-row widget state. */
|
||||||
|
private final Map<Button, String> infoButtonHashes = new HashMap<>();
|
||||||
|
|
||||||
|
private static final SimpleDateFormat INFO_DATE_FMT = new SimpleDateFormat("yyyy-MM-dd");
|
||||||
|
|
||||||
/** Filter bar Y (below the tab row). */
|
/** Filter bar Y (below the tab row). */
|
||||||
private static final int SHARED_FILTER_Y = TAB_Y + TAB_H + 10;
|
private static final int SHARED_FILTER_Y = TAB_Y + TAB_H + 10;
|
||||||
private static final int SHARED_FILTER_H = 20;
|
private static final int SHARED_FILTER_H = 20;
|
||||||
@@ -291,6 +303,7 @@ public class ClosetScreen extends Screen {
|
|||||||
for (Button btn : cosmeticButtons) removeWidget(btn);
|
for (Button btn : cosmeticButtons) removeWidget(btn);
|
||||||
cosmeticButtons.clear();
|
cosmeticButtons.clear();
|
||||||
sharedButtonHashes.clear();
|
sharedButtonHashes.clear();
|
||||||
|
infoButtonHashes.clear();
|
||||||
|
|
||||||
if (selectedCategory == null) return;
|
if (selectedCategory == null) return;
|
||||||
if (SKIN_TAB.equals(selectedCategory)) {
|
if (SKIN_TAB.equals(selectedCategory)) {
|
||||||
@@ -533,6 +546,22 @@ public class ClosetScreen extends Screen {
|
|||||||
del.active = show;
|
del.active = show;
|
||||||
cosmeticButtons.add(del);
|
cosmeticButtons.add(del);
|
||||||
addRenderableWidget(del);
|
addRenderableWidget(del);
|
||||||
|
|
||||||
|
// Small "?" affordance top-left — click to force a fresh request, hover to read
|
||||||
|
// the cached uploader info. Tooltip content is refreshed per-tick from
|
||||||
|
// ClientCosmeticInfoCache as responses land.
|
||||||
|
Button info = Button.builder(
|
||||||
|
Component.literal("?"),
|
||||||
|
b -> ClientCosmeticInfoCache.requestIfMissing(hash)
|
||||||
|
).bounds(boxX, boxY - 12, 12, 12)
|
||||||
|
.tooltip(Tooltip.create(Component.literal("Loading…")))
|
||||||
|
.build();
|
||||||
|
info.visible = show;
|
||||||
|
info.active = show;
|
||||||
|
cosmeticButtons.add(info);
|
||||||
|
addRenderableWidget(info);
|
||||||
|
infoButtonHashes.put(info, hash);
|
||||||
|
ClientCosmeticInfoCache.requestIfMissing(hash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,10 +634,27 @@ public class ClosetScreen extends Screen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void deleteShared(String hash) {
|
private void deleteShared(String hash) {
|
||||||
// Server drops the hash from this player's library; the local call releases the
|
// Preemptively remove the hash from our local library attachment so the immediate
|
||||||
// baked model + texture and deletes the local blob cache file.
|
// rebuildCosmeticRow below doesn't re-create a button for the dying entry and fire
|
||||||
|
// a speculative RequestCosmetic the server can no longer fulfill (which would leave
|
||||||
|
// IN_FLIGHT/PENDING_REQUESTS permanently stuck and break re-uploads of identical
|
||||||
|
// content). The next server sync is authoritative and will overwrite this anyway.
|
||||||
|
LocalPlayer player = Minecraft.getInstance().player;
|
||||||
|
if (player != null) {
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
if (lib.contains(hash)) {
|
||||||
|
player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.remove(hash));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Server drops the hash from this player's library (or globally, if OP); the local
|
||||||
|
// call releases the baked model + texture, deletes the local blob cache file, and
|
||||||
|
// clears any in-flight / pending-request state so a re-upload of identical content
|
||||||
|
// triggers a fresh download.
|
||||||
ClientSharedCosmeticCache.sendToServer(new DeleteCosmetic(hash));
|
ClientSharedCosmeticCache.sendToServer(new DeleteCosmetic(hash));
|
||||||
ClientSharedCosmeticCache.delete(hash);
|
ClientSharedCosmeticCache.delete(hash);
|
||||||
|
// Drop the uploader-info cache too so a re-upload re-fetches the (possibly new)
|
||||||
|
// uploader metadata instead of showing the stale "unknown" result from before.
|
||||||
|
ClientCosmeticInfoCache.forget(hash);
|
||||||
rebuildCosmeticRow();
|
rebuildCosmeticRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,16 +914,17 @@ public class ClosetScreen extends Screen {
|
|||||||
String result = ClientSharedCosmeticUploader.upload(model, texture, name);
|
String result = ClientSharedCosmeticUploader.upload(model, texture, name);
|
||||||
setShareStatus(result);
|
setShareStatus(result);
|
||||||
|
|
||||||
// Clear the fields on successful submission so a second upload starts fresh.
|
// Close the form on successful submission. "Uploading " means chunks went to the
|
||||||
// The uploader's status string is the only signal of outcome — "Uploading " means
|
// server; "Already in library" means dedup short-circuited — both are terminal
|
||||||
// chunks went to the server, "Already in library" means dedup short-circuited
|
// successes for this form, so drop back to the main closet and let the library
|
||||||
// (still a valid state, nothing to retry). Failures keep the fields populated so
|
// sync drive the shared tab refresh. Failures keep the form open so the user can
|
||||||
// the user can correct and resubmit without re-entering paths.
|
// correct the input without re-entering paths.
|
||||||
if (result != null
|
if (result != null
|
||||||
&& (result.startsWith("Uploading ") || result.startsWith("Already in library"))) {
|
&& (result.startsWith("Uploading ") || result.startsWith("Already in library"))) {
|
||||||
shareTextureField.setValue("");
|
shareTextureField.setValue("");
|
||||||
shareModelField.setValue("");
|
shareModelField.setValue("");
|
||||||
shareNameField.setValue("");
|
shareNameField.setValue("");
|
||||||
|
closeShareForm();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -966,6 +1013,49 @@ public class ClosetScreen extends Screen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll the shared-tab grid so a specific hash is visible. If the current bone filter
|
||||||
|
* hides the entry, the filter is cleared to "All" first — otherwise we'd appear to
|
||||||
|
* navigate to an empty grid.
|
||||||
|
*/
|
||||||
|
private void scrollToSharedCosmetic(String hash) {
|
||||||
|
LocalPlayer player = Minecraft.getInstance().player;
|
||||||
|
if (player == null) return;
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
|
||||||
|
int stride = COSMETIC_SIZE + COSMETIC_SPACING;
|
||||||
|
int gridRight = this.width - 10;
|
||||||
|
int boxesPerRow = Math.max(1, (gridRight - COSMETIC_ROW_LEFT + COSMETIC_SPACING) / stride);
|
||||||
|
|
||||||
|
int targetRow = findSharedRow(lib, hash, boxesPerRow, sharedBoneFilter);
|
||||||
|
if (targetRow < 0 && sharedBoneFilter != null) {
|
||||||
|
// Hidden by current filter — drop it and retry with "All".
|
||||||
|
sharedBoneFilter = null;
|
||||||
|
targetRow = findSharedRow(lib, hash, boxesPerRow, null);
|
||||||
|
}
|
||||||
|
if (targetRow < 0) return;
|
||||||
|
sharedVerticalScroll = targetRow * stride;
|
||||||
|
clampSharedScroll();
|
||||||
|
rebuildCosmeticRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row index (0-based) of {@code hash} in the shared grid under the given filter, or -1 if absent. */
|
||||||
|
private int findSharedRow(CosmeticLibrary lib, String hash, int boxesPerRow, @Nullable String filter) {
|
||||||
|
int idx = 0;
|
||||||
|
for (CosmeticLibraryEntry entry : lib.entries()) {
|
||||||
|
if (filter != null) {
|
||||||
|
String saved = sharedBoneFilter;
|
||||||
|
sharedBoneFilter = filter;
|
||||||
|
boolean ok = entryMatchesSharedFilter(entry);
|
||||||
|
sharedBoneFilter = saved;
|
||||||
|
if (!ok) continue;
|
||||||
|
}
|
||||||
|
if (entry.hash().equals(hash)) return idx / boxesPerRow;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean mouseScrolled(double mx, double my, double deltaX, double deltaY) {
|
public boolean mouseScrolled(double mx, double my, double deltaX, double deltaY) {
|
||||||
// Shared tab uses the full right-side region as a vertical scroll area.
|
// Shared tab uses the full right-side region as a vertical scroll area.
|
||||||
@@ -1043,11 +1133,18 @@ public class ClosetScreen extends Screen {
|
|||||||
if (!isOverAnyWidget(mx, my)) {
|
if (!isOverAnyWidget(mx, my)) {
|
||||||
String hit = findClickedEquippedCategory(player, mx, my);
|
String hit = findClickedEquippedCategory(player, mx, my);
|
||||||
if (hit != null) {
|
if (hit != null) {
|
||||||
selectCategory(hit);
|
|
||||||
com.razz.dfashion.cosmetic.CosmeticRef ref =
|
com.razz.dfashion.cosmetic.CosmeticRef ref =
|
||||||
player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit);
|
player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit);
|
||||||
if (ref instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l) {
|
if (ref instanceof com.razz.dfashion.cosmetic.CosmeticRef.Shared s) {
|
||||||
scrollToCosmetic(hit, l.id());
|
// Shared cosmetics don't live on the bone-category tab even
|
||||||
|
// though they appear there — their home is the shared tab.
|
||||||
|
selectCategory(SHARED_TAB);
|
||||||
|
scrollToSharedCosmetic(s.hash());
|
||||||
|
} else {
|
||||||
|
selectCategory(hit);
|
||||||
|
if (ref instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l) {
|
||||||
|
scrollToCosmetic(hit, l.id());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1198,6 +1295,36 @@ public class ClosetScreen extends Screen {
|
|||||||
player.setYHeadRot(displayYaw);
|
player.setYHeadRot(displayYaw);
|
||||||
player.yBodyRotO = displayYaw;
|
player.yBodyRotO = displayYaw;
|
||||||
player.yHeadRotO = displayYaw;
|
player.yHeadRotO = displayYaw;
|
||||||
|
|
||||||
|
refreshInfoTooltips();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite each info-button's tooltip from the current {@link ClientCosmeticInfoCache}
|
||||||
|
* snapshot. Responses arrive asynchronously, so a tooltip might start as "Loading…"
|
||||||
|
* and flip to real uploader text a tick or two later. We overwrite unconditionally —
|
||||||
|
* Tooltip construction is cheap relative to per-hover render work.
|
||||||
|
*/
|
||||||
|
private void refreshInfoTooltips() {
|
||||||
|
if (infoButtonHashes.isEmpty()) return;
|
||||||
|
for (Map.Entry<Button, String> e : infoButtonHashes.entrySet()) {
|
||||||
|
Button button = e.getKey();
|
||||||
|
String hash = e.getValue();
|
||||||
|
ClientCosmeticInfoCache.Info info = ClientCosmeticInfoCache.get(hash);
|
||||||
|
Component tip;
|
||||||
|
if (info == null) {
|
||||||
|
tip = Component.literal("Loading…");
|
||||||
|
} else if (!info.isKnown()) {
|
||||||
|
tip = Component.literal("Uploader: unknown");
|
||||||
|
} else {
|
||||||
|
String who = info.uploaderName().isEmpty() ? "unknown" : info.uploaderName();
|
||||||
|
String when = info.uploadedAt() > 0
|
||||||
|
? INFO_DATE_FMT.format(new Date(info.uploadedAt()))
|
||||||
|
: "unknown date";
|
||||||
|
tip = Component.literal("Uploader: " + who + "\nUploaded: " + when);
|
||||||
|
}
|
||||||
|
button.setTooltip(Tooltip.create(tip));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -1268,6 +1395,8 @@ public class ClosetScreen extends Screen {
|
|||||||
&& entry.getKey().equals(l.id());
|
&& entry.getKey().equals(l.id());
|
||||||
int borderColor = isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR;
|
int borderColor = isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR;
|
||||||
|
|
||||||
|
// Opaque backdrop so the 3D world behind the closet doesn't poke through the preview.
|
||||||
|
graphics.fill(fx0, fy0, fx1, fy1, COSMETIC_BACKDROP_COLOR);
|
||||||
// Frame (green if currently equipped).
|
// Frame (green if currently equipped).
|
||||||
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0, borderColor);
|
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0, borderColor);
|
||||||
|
|
||||||
@@ -1349,6 +1478,7 @@ public class ClosetScreen extends Screen {
|
|||||||
String previewCategory = sharedCategoryForHash(hash);
|
String previewCategory = sharedCategoryForHash(hash);
|
||||||
CosmeticRef liveRef = liveEquipped.get(previewCategory);
|
CosmeticRef liveRef = liveEquipped.get(previewCategory);
|
||||||
boolean isEquipped = liveRef instanceof CosmeticRef.Shared s && hash.equals(s.hash());
|
boolean isEquipped = liveRef instanceof CosmeticRef.Shared s && hash.equals(s.hash());
|
||||||
|
graphics.fill(fx0, fy0, fx1, fy1, COSMETIC_BACKDROP_COLOR);
|
||||||
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0,
|
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0,
|
||||||
isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_SHARED_COLOR);
|
isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_SHARED_COLOR);
|
||||||
|
|
||||||
@@ -1404,6 +1534,7 @@ public class ClosetScreen extends Screen {
|
|||||||
if (tex == null) continue;
|
if (tex == null) continue;
|
||||||
|
|
||||||
boolean isEquipped = liveSkin != null && hash.equals(liveSkin.hash());
|
boolean isEquipped = liveSkin != null && hash.equals(liveSkin.hash());
|
||||||
|
graphics.fill(fx0, fy0, fx1, fy1, COSMETIC_BACKDROP_COLOR);
|
||||||
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0,
|
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0,
|
||||||
isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR);
|
isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR);
|
||||||
|
|
||||||
|
|||||||
@@ -105,4 +105,4 @@ public final class CosmeticCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private CosmeticCommands() {}
|
private CosmeticCommands() {}
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,10 @@ import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
|||||||
import com.razz.dfashion.cosmetic.CosmeticRef;
|
import com.razz.dfashion.cosmetic.CosmeticRef;
|
||||||
import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic;
|
import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic;
|
||||||
import com.razz.dfashion.cosmetic.share.packet.CosmeticChunk;
|
import com.razz.dfashion.cosmetic.share.packet.CosmeticChunk;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.CosmeticInfoResponse;
|
||||||
import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic;
|
import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic;
|
||||||
import com.razz.dfashion.cosmetic.share.packet.RequestCosmetic;
|
import com.razz.dfashion.cosmetic.share.packet.RequestCosmetic;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.RequestCosmeticInfo;
|
||||||
import com.razz.dfashion.cosmetic.share.packet.UploadCosmeticChunk;
|
import com.razz.dfashion.cosmetic.share.packet.UploadCosmeticChunk;
|
||||||
|
|
||||||
import io.netty.buffer.ByteBuf;
|
import io.netty.buffer.ByteBuf;
|
||||||
@@ -65,10 +67,18 @@ public final class CosmeticShareNetwork {
|
|||||||
AssignSharedCosmetic.TYPE, AssignSharedCosmetic.STREAM_CODEC,
|
AssignSharedCosmetic.TYPE, AssignSharedCosmetic.STREAM_CODEC,
|
||||||
CosmeticShareNetwork::onAssignShared
|
CosmeticShareNetwork::onAssignShared
|
||||||
);
|
);
|
||||||
|
registrar.playToServer(
|
||||||
|
RequestCosmeticInfo.TYPE, RequestCosmeticInfo.STREAM_CODEC,
|
||||||
|
CosmeticShareNetwork::onRequestCosmeticInfo
|
||||||
|
);
|
||||||
registrar.playToClient(
|
registrar.playToClient(
|
||||||
CosmeticChunk.TYPE, CosmeticChunk.STREAM_CODEC,
|
CosmeticChunk.TYPE, CosmeticChunk.STREAM_CODEC,
|
||||||
CosmeticShareNetwork::onCosmeticChunk
|
CosmeticShareNetwork::onCosmeticChunk
|
||||||
);
|
);
|
||||||
|
registrar.playToClient(
|
||||||
|
CosmeticInfoResponse.TYPE, CosmeticInfoResponse.STREAM_CODEC,
|
||||||
|
CosmeticShareNetwork::onCosmeticInfoResponse
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear any in-flight upload state when a player disconnects so buffers don't leak. */
|
/** Clear any in-flight upload state when a player disconnects so buffers don't leak. */
|
||||||
@@ -98,9 +108,18 @@ public final class CosmeticShareNetwork {
|
|||||||
boolean anyChanged = false;
|
boolean anyChanged = false;
|
||||||
List<CosmeticLibraryEntry> migrated = new ArrayList<>(lib.entries().size());
|
List<CosmeticLibraryEntry> migrated = new ArrayList<>(lib.entries().size());
|
||||||
for (CosmeticLibraryEntry entry : lib.entries()) {
|
for (CosmeticLibraryEntry entry : lib.entries()) {
|
||||||
|
// If the blob is gone (e.g. OP admin deletion) drop the library entry so it
|
||||||
|
// doesn't linger as a broken ref. This is the offline-player branch of the
|
||||||
|
// admin-delete cleanup — online players are pruned directly at delete time.
|
||||||
|
if (!SharedCosmeticCache.has(server, entry.hash())) {
|
||||||
|
anyChanged = true;
|
||||||
|
DecoFashion.LOGGER.info("Cosmetic library pruned (blob missing): player={} hash={}",
|
||||||
|
player.getUUID(), entry.hash());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
List<String> extracted = extractBonesFromStoredBlob(server, entry.hash());
|
List<String> extracted = extractBonesFromStoredBlob(server, entry.hash());
|
||||||
if (extracted == null) {
|
if (extracted == null) {
|
||||||
migrated.add(entry); // read/decode failed — leave untouched
|
migrated.add(entry); // decode failed — leave untouched
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (extracted.equals(entry.bones())) {
|
if (extracted.equals(entry.bones())) {
|
||||||
@@ -185,6 +204,11 @@ public final class CosmeticShareNetwork {
|
|||||||
);
|
);
|
||||||
if (finalized == null) return;
|
if (finalized == null) return;
|
||||||
|
|
||||||
|
// Remember who first uploaded content for this hash. Content-addressed dedup
|
||||||
|
// means duplicate uploads land on the same blob — first-uploader wins.
|
||||||
|
CosmeticUploadRegistry.recordIfAbsent(
|
||||||
|
server, finalized.hash(), player.getUUID(), player.getGameProfile().name());
|
||||||
|
|
||||||
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
if (!lib.contains(finalized.hash())) {
|
if (!lib.contains(finalized.hash())) {
|
||||||
int n = lib.entries().size() + 1;
|
int n = lib.entries().size() + 1;
|
||||||
@@ -293,6 +317,18 @@ public final class CosmeticShareNetwork {
|
|||||||
DecoFashion.LOGGER.warn("DeleteCosmetic from {}: invalid hash rejected", player.getUUID());
|
DecoFashion.LOGGER.warn("DeleteCosmetic from {}: invalid hash rejected", player.getUUID());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OPs (gamemaster+) always delete globally — file, uploader record, and every
|
||||||
|
// online player's library/equipped state. Offline players self-heal on login.
|
||||||
|
// Non-OPs only affect their own library.
|
||||||
|
MinecraftServer server = player.level().getServer();
|
||||||
|
boolean isOp = server != null
|
||||||
|
&& player.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER);
|
||||||
|
if (isOp) {
|
||||||
|
adminDelete(server, msg.hash());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
if (!lib.contains(msg.hash())) return;
|
if (!lib.contains(msg.hash())) return;
|
||||||
player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.remove(msg.hash()));
|
player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.remove(msg.hash()));
|
||||||
@@ -317,10 +353,41 @@ public final class CosmeticShareNetwork {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void onRequestCosmeticInfo(RequestCosmeticInfo msg, IPayloadContext ctx) {
|
||||||
|
ctx.enqueueWork(() -> {
|
||||||
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
|
MinecraftServer server = player.level().getServer();
|
||||||
|
if (server == null) return;
|
||||||
|
if (!SharedCosmeticCache.isValidHash(msg.hash())) {
|
||||||
|
DecoFashion.LOGGER.warn("CosmeticInfo request from {}: invalid hash rejected", player.getUUID());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-OPs can only look up hashes in their own library — otherwise anyone could
|
||||||
|
// enumerate every blob on the server.
|
||||||
|
boolean isOp = player.permissions()
|
||||||
|
.hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER);
|
||||||
|
if (!isOp) {
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
if (!lib.contains(msg.hash())) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CosmeticUploadRegistry.UploadRecord rec = CosmeticUploadRegistry.get(server, msg.hash());
|
||||||
|
String name = rec == null ? "" : rec.uploaderName();
|
||||||
|
long uploadedAt = rec == null ? 0L : rec.uploadedAt();
|
||||||
|
player.connection.send(new ClientboundCustomPayloadPacket(
|
||||||
|
new CosmeticInfoResponse(msg.hash(), name, uploadedAt)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static void onCosmeticChunk(CosmeticChunk msg, IPayloadContext ctx) {
|
private static void onCosmeticChunk(CosmeticChunk msg, IPayloadContext ctx) {
|
||||||
ctx.enqueueWork(() -> ClientReceiver.accept(msg));
|
ctx.enqueueWork(() -> ClientReceiver.accept(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void onCosmeticInfoResponse(CosmeticInfoResponse msg, IPayloadContext ctx) {
|
||||||
|
ctx.enqueueWork(() -> InfoReceiver.accept(msg));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-only trampoline. Kept separate so the server-side handler routing can be
|
* Client-only trampoline. Kept separate so the server-side handler routing can be
|
||||||
* dist-gated without pulling in any client-only classes on dedicated servers.
|
* dist-gated without pulling in any client-only classes on dedicated servers.
|
||||||
@@ -338,6 +405,63 @@ public final class CosmeticShareNetwork {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Client-only trampoline for {@link CosmeticInfoResponse}. Same dist-gating rationale as {@link ClientReceiver}. */
|
||||||
|
private static final class InfoReceiver {
|
||||||
|
static void accept(CosmeticInfoResponse msg) {
|
||||||
|
if (net.neoforged.fml.loading.FMLEnvironment.getDist() != Dist.CLIENT) return;
|
||||||
|
com.razz.dfashion.client.ClientCosmeticInfoCache.onResponse(
|
||||||
|
msg.hash(), msg.uploaderName(), msg.uploadedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-level delete: removes the {@code .dfcos} blob, forgets the uploader record,
|
||||||
|
* and prunes every online player's library + equipped slots that referenced the hash.
|
||||||
|
* Offline players pick up the cleanup on their next login via {@link #onPlayerLoggedIn}.
|
||||||
|
*
|
||||||
|
* <p>Caller is responsible for the permission check — this method itself is unguarded
|
||||||
|
* so it can be invoked from either command or packet paths.
|
||||||
|
*
|
||||||
|
* @return {@code true} if a blob file was deleted; {@code false} if the hash was unknown.
|
||||||
|
*/
|
||||||
|
public static boolean adminDelete(MinecraftServer server, String hash) {
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash)) return false;
|
||||||
|
boolean deleted;
|
||||||
|
try {
|
||||||
|
deleted = SharedCosmeticCache.deleteBlob(server, hash);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.error("Admin cosmetic delete: file delete failed for {}", hash, ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CosmeticUploadRegistry.forget(server, hash);
|
||||||
|
|
||||||
|
int prunedPlayers = 0;
|
||||||
|
int unequipped = 0;
|
||||||
|
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
if (lib.contains(hash)) {
|
||||||
|
player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.remove(hash));
|
||||||
|
prunedPlayers++;
|
||||||
|
}
|
||||||
|
Map<String, CosmeticRef> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||||
|
Map<String, CosmeticRef> updated = null;
|
||||||
|
for (Map.Entry<String, CosmeticRef> e : equipped.entrySet()) {
|
||||||
|
if (e.getValue() instanceof CosmeticRef.Shared s && hash.equals(s.hash())) {
|
||||||
|
if (updated == null) updated = new HashMap<>(equipped);
|
||||||
|
updated.remove(e.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated != null) {
|
||||||
|
player.setData(CosmeticAttachments.EQUIPPED.get(), updated);
|
||||||
|
unequipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DecoFashion.LOGGER.info("Admin cosmetic delete: hash={} fileDeleted={} prunedLibraries={} unequippedPlayers={}",
|
||||||
|
hash, deleted, prunedPlayers, unequipped);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
/** Slice bytes from the virtual concatenation of {@code a ‖ b} into {@code out}, avoiding a full copy. */
|
/** Slice bytes from the virtual concatenation of {@code a ‖ b} into {@code out}, avoiding a full copy. */
|
||||||
private static void copySlice(byte[] a, byte[] b, int off, byte[] out) {
|
private static void copySlice(byte[] a, byte[] b, int off, byte[] out) {
|
||||||
int dst = 0;
|
int dst = 0;
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.WeakHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side index mapping a shared-cosmetic hash to the player who first uploaded it.
|
||||||
|
* Persisted as JSON alongside the {@code .dfcos} blobs in {@link SharedCosmeticCache}.
|
||||||
|
*
|
||||||
|
* <p>Content is content-addressed, so byte-identical uploads from two players dedup on
|
||||||
|
* disk. The registry records the <b>first</b> uploader and never overwrites — otherwise
|
||||||
|
* an attacker could launder authorship by re-uploading a popular cosmetic.
|
||||||
|
*/
|
||||||
|
public final class CosmeticUploadRegistry {
|
||||||
|
|
||||||
|
public record UploadRecord(UUID uploader, String uploaderName, long uploadedAt) {}
|
||||||
|
|
||||||
|
private static final String FILE_NAME = "uploaders.json";
|
||||||
|
|
||||||
|
/** One in-memory cache per live {@link MinecraftServer}. WeakHashMap so server shutdown
|
||||||
|
* drops the entry automatically. Per-map writes are synchronized on the map. */
|
||||||
|
private static final Map<MinecraftServer, Map<String, UploadRecord>> BY_SERVER =
|
||||||
|
Collections.synchronizedMap(new WeakHashMap<>());
|
||||||
|
|
||||||
|
private CosmeticUploadRegistry() {}
|
||||||
|
|
||||||
|
private static Path fileFor(MinecraftServer server) {
|
||||||
|
return server.getServerDirectory().resolve("decofashion/cosmetics").resolve(FILE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static synchronized Map<String, UploadRecord> getOrLoad(MinecraftServer server) {
|
||||||
|
Map<String, UploadRecord> cached = BY_SERVER.get(server);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
Map<String, UploadRecord> loaded = loadFromDisk(server);
|
||||||
|
BY_SERVER.put(server, loaded);
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, UploadRecord> loadFromDisk(MinecraftServer server) {
|
||||||
|
Map<String, UploadRecord> out = new HashMap<>();
|
||||||
|
Path path = fileFor(server);
|
||||||
|
if (!Files.isRegularFile(path)) return out;
|
||||||
|
try {
|
||||||
|
String json = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
|
JsonElement root = JsonParser.parseString(json);
|
||||||
|
if (!root.isJsonObject()) return out;
|
||||||
|
for (Map.Entry<String, JsonElement> e : root.getAsJsonObject().entrySet()) {
|
||||||
|
String hash = e.getKey();
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash)) continue;
|
||||||
|
if (!e.getValue().isJsonObject()) continue;
|
||||||
|
JsonObject row = e.getValue().getAsJsonObject();
|
||||||
|
String uuidStr = row.has("uuid") ? row.get("uuid").getAsString() : null;
|
||||||
|
String name = row.has("name") ? row.get("name").getAsString() : "";
|
||||||
|
long ts = row.has("uploadedAt") ? row.get("uploadedAt").getAsLong() : 0L;
|
||||||
|
if (uuidStr == null) continue;
|
||||||
|
try {
|
||||||
|
out.put(hash, new UploadRecord(UUID.fromString(uuidStr), name, ts));
|
||||||
|
} catch (IllegalArgumentException bad) {
|
||||||
|
// skip malformed entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException | RuntimeException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("CosmeticUploadRegistry: load failed ({}); starting empty", ex.getMessage());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void saveToDisk(MinecraftServer server, Map<String, UploadRecord> data) {
|
||||||
|
Path path = fileFor(server);
|
||||||
|
try {
|
||||||
|
Files.createDirectories(path.getParent());
|
||||||
|
JsonObject root = new JsonObject();
|
||||||
|
// Snapshot under the map's monitor so we don't serialize during a mutation.
|
||||||
|
synchronized (data) {
|
||||||
|
for (Map.Entry<String, UploadRecord> e : data.entrySet()) {
|
||||||
|
JsonObject row = new JsonObject();
|
||||||
|
row.addProperty("uuid", e.getValue().uploader().toString());
|
||||||
|
row.addProperty("name", e.getValue().uploaderName());
|
||||||
|
row.addProperty("uploadedAt", e.getValue().uploadedAt());
|
||||||
|
root.add(e.getKey(), row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Path tmp = path.resolveSibling(FILE_NAME + ".tmp");
|
||||||
|
Files.writeString(tmp, root.toString(), StandardCharsets.UTF_8);
|
||||||
|
Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("CosmeticUploadRegistry: save failed ({})", ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record the first uploader for a hash. Subsequent calls for the same hash are no-ops. */
|
||||||
|
public static void recordIfAbsent(MinecraftServer server, String hash, UUID uploader, String uploaderName) {
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash) || uploader == null) return;
|
||||||
|
Map<String, UploadRecord> map = getOrLoad(server);
|
||||||
|
boolean changed;
|
||||||
|
synchronized (map) {
|
||||||
|
changed = !map.containsKey(hash);
|
||||||
|
if (changed) {
|
||||||
|
map.put(hash, new UploadRecord(uploader, uploaderName == null ? "" : uploaderName,
|
||||||
|
System.currentTimeMillis()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) saveToDisk(server, map);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UploadRecord get(MinecraftServer server, String hash) {
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash)) return null;
|
||||||
|
Map<String, UploadRecord> map = getOrLoad(server);
|
||||||
|
synchronized (map) {
|
||||||
|
return map.get(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a snapshot of the full registry. Caller owns the returned map. */
|
||||||
|
public static Map<String, UploadRecord> snapshot(MinecraftServer server) {
|
||||||
|
Map<String, UploadRecord> map = getOrLoad(server);
|
||||||
|
synchronized (map) {
|
||||||
|
return new HashMap<>(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop any record for a hash (called when the blob is deleted). No-op if absent. */
|
||||||
|
public static void forget(MinecraftServer server, String hash) {
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash)) return;
|
||||||
|
Map<String, UploadRecord> map = getOrLoad(server);
|
||||||
|
boolean changed;
|
||||||
|
synchronized (map) {
|
||||||
|
changed = map.remove(hash) != null;
|
||||||
|
}
|
||||||
|
if (changed) saveToDisk(server, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -410,6 +410,15 @@ public final class SharedCosmeticCache {
|
|||||||
return new Finalized(hash, asm.width, asm.height, canonicalBbmodelBin, bones);
|
return new Finalized(hash, asm.width, asm.height, canonicalBbmodelBin, bones);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a blob from disk. Returns {@code true} if a file was deleted. Idempotent —
|
||||||
|
* calling on an unknown or already-removed hash is safe and returns {@code false}.
|
||||||
|
*/
|
||||||
|
public static boolean deleteBlob(MinecraftServer server, String hash) throws IOException {
|
||||||
|
if (!isValidHash(hash)) throw new IOException("invalid hash");
|
||||||
|
return Files.deleteIfExists(fileFor(server, hash));
|
||||||
|
}
|
||||||
|
|
||||||
/** Enumerate stored hashes with their on-disk sizes — useful for admin tooling. */
|
/** Enumerate stored hashes with their on-disk sizes — useful for admin tooling. */
|
||||||
public static Map<String, Long> listCached(MinecraftServer server) {
|
public static Map<String, Long> listCached(MinecraftServer server) {
|
||||||
Map<String, Long> out = new HashMap<>();
|
Map<String, Long> out = new HashMap<>();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share.packet;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server → client. Uploader metadata response for a specific cosmetic hash.
|
||||||
|
*
|
||||||
|
* <p>{@code uploaderName} is empty if the uploader is unknown (legacy uploads made before
|
||||||
|
* the registry existed, or blobs cleared from the registry). {@code uploadedAt} is epoch
|
||||||
|
* millis; {@code 0} means "unknown".
|
||||||
|
*/
|
||||||
|
public record CosmeticInfoResponse(String hash, String uploaderName, long uploadedAt) implements CustomPacketPayload {
|
||||||
|
|
||||||
|
public static final Type<CosmeticInfoResponse> TYPE =
|
||||||
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "cosmetic_info"));
|
||||||
|
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, CosmeticInfoResponse> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(
|
||||||
|
ByteBufCodecs.stringUtf8(64), CosmeticInfoResponse::hash,
|
||||||
|
ByteBufCodecs.stringUtf8(128), CosmeticInfoResponse::uploaderName,
|
||||||
|
ByteBufCodecs.VAR_LONG, CosmeticInfoResponse::uploadedAt,
|
||||||
|
CosmeticInfoResponse::new
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<CosmeticInfoResponse> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share.packet;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client → server. Request uploader metadata for {@code hash}. Server responds with a
|
||||||
|
* {@link CosmeticInfoResponse} iff the hash is valid and either (a) it's in the caller's
|
||||||
|
* personal library or (b) the caller is an OP — to block arbitrary blob enumeration.
|
||||||
|
*/
|
||||||
|
public record RequestCosmeticInfo(String hash) implements CustomPacketPayload {
|
||||||
|
|
||||||
|
public static final Type<RequestCosmeticInfo> TYPE =
|
||||||
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "request_cosmetic_info"));
|
||||||
|
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, RequestCosmeticInfo> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(
|
||||||
|
ByteBufCodecs.stringUtf8(64), RequestCosmeticInfo::hash,
|
||||||
|
RequestCosmeticInfo::new
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<RequestCosmeticInfo> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user