Compare commits

..

10 Commits

Author SHA1 Message Date
gamer147
96e47515b1 Use reflection for JSON parsing and fix question state issue 2025-11-25 20:53:54 -05:00
gamer147
9898b2ed07 Fix random ql support 2025-11-25 17:38:54 -05:00
gamer147
89fae5a5a2 Moving away from tree style for the image hflow style, but this works for now I guess 2025-11-24 23:08:36 -05:00
gamer147
6caccf553e Works aside from buy button being missing 2025-11-24 22:41:12 -05:00
gamer147
7263b88baa Good spot 2025-11-24 21:41:28 -05:00
gamer147
964fcba8bc Good spot 2025-11-24 21:37:13 -05:00
gamer147
16bfe74417 It works 2025-11-24 21:12:49 -05:00
gamer147
76400a6cae Action might work? 2025-11-24 15:39:19 -05:00
gamer147
7d35101dbf Question might work? 2025-11-24 15:29:01 -05:00
gamer147
7cc3f0e184 Basic loaders implemented 2025-11-24 15:23:38 -05:00
15 changed files with 1082 additions and 23 deletions

View File

@@ -1,3 +1,6 @@
import org.gradle.api.tasks.bundling.Jar
import org.gradle.api.tasks.bundling.Zip
plugins { plugins {
id("java") id("java")
} }
@@ -14,4 +17,15 @@ repositories {
dependencies { dependencies {
implementation("org.gotti.wurmunlimited:server-modlauncher:0.46") implementation("org.gotti.wurmunlimited:server-modlauncher:0.46")
} }
tasks.register<Zip>("dist") {
into("mods") {
into(project.name) {
from(tasks.named<Jar>("jar"))
}
from(fileTree("mods") { include("*") })
}
archiveFileName.set("${project.name}.zip")
}

View File

@@ -2,6 +2,23 @@ classname=mod.treestar.shopmod.ShopMod
classpath=ShopMod.jar classpath=ShopMod.jar
sharedClassLoader=true sharedClassLoader=true
# Activates the json item provider and loads items from the given configuration # Display name shown in the shop window
jsonItemProvider=true shopName=Server Shop
jsonItemProviderItems=mods/shop_items.json
# Allow opening the shop from settlement tokens
enableTokenAccess=true
# Allow opening the shop from mailboxes
enableMailboxAccess=false
# JSON config files for categories and items
categoryJsonPath=mods/shop_categories.json
itemJsonPath=mods/shop_items.json
# java.util.logging level for this mod (e.g., INFO, FINE)
logLevel=INFO
# Dump trader inventories to a shop_items.json-compatible file on startup (set back to false after dumping)
dumpTraders=true
dumpTradersCategoryId=1
dumpTradersOutputPath=mods/shop_items.json

View File

@@ -3,5 +3,10 @@
"id": 1, "id": 1,
"name": "Consumables", "name": "Consumables",
"description": "Limited usage consumable items." "description": "Limited usage consumable items."
},
{
"id": 2,
"name": "Services",
"description": "Non-item services and boosts."
} }
] ]

View File

@@ -4,7 +4,33 @@
"categoryId": 1, "categoryId": 1,
"name": "Sleep Powder", "name": "Sleep Powder",
"description": "Grants 1 hour of sleep bonus.", "description": "Grants 1 hour of sleep bonus.",
"ironPrice": 50000, "image": "https://www.wurmpedia.com/images/5/5e/Sleep_powder.png",
"image": "https://www.wurmpedia.com/images/5/5e/Sleep_powder.png" "currency": {
"type": "WurmBankCurrency",
"ironAmount": 50000
},
"handler": {
"type": "ShopWurmItemPurchaseEffect",
"itemTemplateId": 740,
"ql": 99.0,
"rarity": 0
}
},
{
"id": 2,
"categoryId": 1,
"name": "Res Stone",
"description": "Gives you one resurrection charge.",
"image": "https://www.wurmpedia.com/images/a/aa/Resurrection_stone_icon.png",
"currency": {
"type": "WurmBankCurrency",
"ironAmount": 100000
},
"handler": {
"type": "ShopWurmItemPurchaseEffect",
"itemTemplateId": 302,
"ql": 99.0,
"rarity": 0
}
} }
] ]

View File

@@ -1 +1 @@
rootProject.name = "SilverShop" rootProject.name = "ShopMod"

View File

@@ -2,28 +2,167 @@ package com.wurmonline.server.questions;
import com.wurmonline.server.NoSuchPlayerException; import com.wurmonline.server.NoSuchPlayerException;
import com.wurmonline.server.Players; import com.wurmonline.server.Players;
import com.wurmonline.server.players.Player;
import mod.treestar.shopmod.ShopService; import mod.treestar.shopmod.ShopService;
import mod.treestar.shopmod.datamodels.ShopCategory;
import mod.treestar.shopmod.datamodels.ShopItem;
import mod.treestar.shopmod.util.BmlForm;
import java.util.List;
import java.util.Properties; import java.util.Properties;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
public class ShopQuestion extends Question { public class ShopQuestion extends Question {
private static final int SHOP_QUESTION_ID = 90001; private static final int SHOP_QUESTION_ID = 90001;
private static final Logger logger = Logger.getLogger(ShopQuestion.class.getName());
private ShopService shopService; private ShopService shopService;
private int selectedCategoryId = -1;
public ShopQuestion(long responderId, String shopName, ShopService shopService) throws NoSuchPlayerException { public ShopQuestion(long responderId, String shopName, ShopService shopService) throws NoSuchPlayerException {
this(responderId, shopName, shopService, -1);
}
public ShopQuestion(long responderId, String shopName, ShopService shopService, int selectedCategoryId) throws NoSuchPlayerException {
super(Players.getInstance().getPlayer(responderId), shopName, null, SHOP_QUESTION_ID, responderId); super(Players.getInstance().getPlayer(responderId), shopName, null, SHOP_QUESTION_ID, responderId);
this.shopService = shopService; this.shopService = shopService;
List<ShopCategory> categories = shopService.getCategories();
if (selectedCategoryId >= 0 && categories.stream().anyMatch(c -> c.getId() == selectedCategoryId)) {
this.selectedCategoryId = selectedCategoryId;
} else if (!categories.isEmpty()) {
this.selectedCategoryId = categories.get(0).getId();
}
} }
@Override @Override
public void answer(Properties properties) { public void answer(Properties properties) {
super.answer(properties); logger.log(Level.INFO, "ShopQuestion.answer: received properties {0}", properties);
List<ShopCategory> categories = shopService.getCategories();
String categoryIndex = properties.getProperty("category");
int newCategoryId = selectedCategoryId;
if (categoryIndex != null && !categories.isEmpty()) {
try {
int idx = Integer.parseInt(categoryIndex);
if (idx >= 0 && idx < categories.size()) {
newCategoryId = categories.get(idx).getId();
logger.log(Level.INFO, "ShopQuestion.answer: category set to index {0}, id {1}", new Object[] { idx, newCategoryId });
}
} catch (NumberFormatException ignored) {
logger.log(Level.INFO, "ShopQuestion.answer: invalid category index {0}", categoryIndex);
}
}
String buyKey = properties.stringPropertyNames().stream()
.filter(k -> k.startsWith("buy_") && properties.getProperty(k).equals("true"))
.findFirst()
.orElse(null);
if (properties.containsKey("close")) {
logger.log(Level.INFO, "ShopQuestion.answer: close requested");
return; // user closed the window
}
if (properties.containsKey("refresh")) {
logger.log(Level.INFO, "ShopQuestion.answer: refresh requested");
createNewQuestion(newCategoryId);
return;
}
if (buyKey == null) { // category change or refresh
logger.log(Level.INFO, "ShopQuestion.answer: no buy key present, refreshing UI");
createNewQuestion(newCategoryId);
return;
}
try {
int itemId = Integer.parseInt(buyKey.substring("buy_".length()));
logger.log(Level.INFO, "ShopQuestion.answer: attempting purchase itemId={0}", itemId);
ShopService.PurchaseResult result = shopService.purchaseItem((Player) getResponder(), itemId);
if (result.isSuccess()) {
logger.log(Level.INFO, "ShopQuestion.answer: purchase success itemId={0}", itemId);
getResponder().getCommunicator().sendSafeServerMessage(result.getMessage());
} else {
logger.log(Level.INFO, "ShopQuestion.answer: purchase failed itemId={0}, message={1}", new Object[] { itemId, result.getMessage() });
getResponder().getCommunicator().sendAlertServerMessage(result.getMessage());
}
// Reopen the shop after purchase
createNewQuestion(newCategoryId);
} catch (NumberFormatException e) {
logger.log(Level.INFO, "ShopQuestion.answer: invalid buy key {0}", buyKey);
getResponder().getCommunicator().sendNormalServerMessage("Invalid item selected.");
}
}
private void createNewQuestion(int categoryId) {
try {
ShopQuestion newQuestion = new ShopQuestion(getResponder().getWurmId(), title, shopService, categoryId);
newQuestion.sendQuestion();
} catch (NoSuchPlayerException e) {
logger.log(Level.WARNING, "Failed to create new ShopQuestion", e);
}
} }
@Override @Override
public void sendQuestion() { public void sendQuestion() {
super.sendQuestion(); List<ShopCategory> categories = shopService.getCategories();
categories = categories.stream()
.filter(c -> shopService.getItems().stream().anyMatch(i -> i.getCategoryId() == c.getId()))
.collect(Collectors.toList());
if (categories.isEmpty()) {
getResponder().getCommunicator().sendNormalServerMessage("No shop categories are available.");
return;
}
if (selectedCategoryId == -1 || categories.stream().noneMatch(c -> c.getId() == selectedCategoryId)) {
selectedCategoryId = categories.get(0).getId();
}
List<ShopItem> items = shopService.getItems().stream()
.filter(i -> i.getCategoryId() == selectedCategoryId)
.collect(Collectors.toList());
if (items.isEmpty()) {
getResponder().getCommunicator().sendNormalServerMessage("No shop items are available.");
return;
}
BmlForm form = new BmlForm(title);
form.addHidden("id", String.valueOf(this.id));
// Category selector with refresh button
if (categories.size() > 1) {
String options = categories.stream().map(c -> escape(c.getName())).collect(Collectors.joining(","));
int defaultIndex = 0;
for (int i = 0; i < categories.size(); i++) {
if (categories.get(i).getId() == selectedCategoryId) {
defaultIndex = i;
break;
}
}
form.beginHorizontalFlow();
form.addLabel("Category");
form.addRaw("dropdown{id=\"" + escape("category") + "\";options=\"" + options + "\";default=\"" + defaultIndex + "\"}");
form.addButton("Show", "refresh");
form.endHorizontalFlow();
}
// Items tree (hover shows description)
int height = 16 + 16 * items.size();
String header = "height=\"" + height + "\"col{text=\"Price\";width=\"80\"};col{text=\"Buy\";width=\"50\"};";
form.beginTree("shopItems", 2, header);
for (ShopItem item : items) {
String priceCell = "text=\"" + escape(item.getPriceDisplay()) + "\"";
String buyCell = "id=\"buy_" + item.getId() + "\";text=\"+1\";checkbox=\"true\"";
form.addTreeRow("i" + item.getId(), item.getName(), item.getDescription(), priceCell, buyCell);
}
form.endTree();
form.beginHorizontalFlow();
form.addButton("Close", "close");
form.addButton("Checkout", "checkout");
form.endHorizontalFlow();
String bml = form.toString();
logger.log(Level.INFO, "ShopQuestion BML: {0}", bml);
getResponder().getCommunicator().sendBml(400, 400, true, true, bml, 200, 200, 200, title);
} }
public ShopService getShopService() { public ShopService getShopService() {
@@ -33,4 +172,11 @@ public class ShopQuestion extends Question {
public void setShopService(ShopService shopService) { public void setShopService(ShopService shopService) {
this.shopService = shopService; this.shopService = shopService;
} }
private String escape(String value) {
if (value == null) {
return "";
}
return value.replace("\"", "''").replace("'", "''");
}
} }

View File

@@ -2,15 +2,19 @@ package mod.treestar.shopmod;
import mod.treestar.shopmod.categoryprovider.JsonShopCategoryProvider; import mod.treestar.shopmod.categoryprovider.JsonShopCategoryProvider;
import mod.treestar.shopmod.itemprovider.JsonShopItemProvider; import mod.treestar.shopmod.itemprovider.JsonShopItemProvider;
import mod.treestar.shopmod.ShopOpenAction;
import mod.treestar.shopmod.util.TraderItemExporter;
import org.gotti.wurmunlimited.modloader.interfaces.Configurable; import org.gotti.wurmunlimited.modloader.interfaces.Configurable;
import org.gotti.wurmunlimited.modloader.interfaces.Initable; import org.gotti.wurmunlimited.modloader.interfaces.Initable;
import org.gotti.wurmunlimited.modloader.interfaces.ServerStartedListener;
import org.gotti.wurmunlimited.modloader.interfaces.WurmServerMod; import org.gotti.wurmunlimited.modloader.interfaces.WurmServerMod;
import org.gotti.wurmunlimited.modsupport.actions.ModActions;
import java.util.Properties; import java.util.Properties;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
public class ShopMod implements WurmServerMod, Initable, Configurable { public class ShopMod implements WurmServerMod, Initable, Configurable, ServerStartedListener {
private static final Logger logger = Logger.getLogger(ShopMod.class.getName()); private static final Logger logger = Logger.getLogger(ShopMod.class.getName());
private String shopName = "Server Shop"; private String shopName = "Server Shop";
@@ -19,6 +23,10 @@ public class ShopMod implements WurmServerMod, Initable, Configurable {
private String categoryJsonPath = "mods/shop/categories.json"; private String categoryJsonPath = "mods/shop/categories.json";
private String itemJsonPath = "mods/shop/items.json"; private String itemJsonPath = "mods/shop/items.json";
private Level logLevel = Level.INFO; private Level logLevel = Level.INFO;
private ShopService shopService;
private boolean dumpTraders = false;
private int dumpTradersCategoryId = 1;
private String dumpTradersOutputPath = "mods/shop/items.json";
@Override @Override
public void configure(Properties properties) { public void configure(Properties properties) {
@@ -28,17 +36,34 @@ public class ShopMod implements WurmServerMod, Initable, Configurable {
categoryJsonPath = properties.getProperty("categoryJsonPath", categoryJsonPath); categoryJsonPath = properties.getProperty("categoryJsonPath", categoryJsonPath);
itemJsonPath = properties.getProperty("itemJsonPath", itemJsonPath); itemJsonPath = properties.getProperty("itemJsonPath", itemJsonPath);
logLevel = getLogLevel(properties.getProperty("logLevel", logLevel.getName())); logLevel = getLogLevel(properties.getProperty("logLevel", logLevel.getName()));
dumpTraders = getBoolean(properties, "dumpTraders", dumpTraders);
dumpTradersCategoryId = getInt(properties, "dumpTradersCategoryId", dumpTradersCategoryId);
dumpTradersOutputPath = properties.getProperty("dumpTradersOutputPath", itemJsonPath);
logger.setLevel(logLevel); logger.setLevel(logLevel);
} }
@Override @Override
public void init() { public void init() {
ShopService shopService = ShopService.getInstance(); ModActions.init();
shopService = ShopService.getInstance();
shopService.registerCategoryProvider(new JsonShopCategoryProvider(categoryJsonPath)); shopService.registerCategoryProvider(new JsonShopCategoryProvider(categoryJsonPath));
shopService.registerItemProvider(new JsonShopItemProvider(itemJsonPath)); shopService.registerItemProvider(new JsonShopItemProvider(itemJsonPath));
}
@Override
public void onServerStarted() {
new ShopOpenAction(shopService, shopName, enableTokenAccess, enableMailboxAccess);
logger.log(Level.INFO, String.format("Initialized shop '%s' (token access: %s, mailbox access: %s)", shopName, enableTokenAccess, enableMailboxAccess)); logger.log(Level.INFO, String.format("Initialized shop '%s' (token access: %s, mailbox access: %s)", shopName, enableTokenAccess, enableMailboxAccess));
if (dumpTraders) {
try {
TraderItemExporter.dumpTraderItems(dumpTradersOutputPath, dumpTradersCategoryId);
} catch (Exception e) {
logger.log(Level.WARNING, "Failed to dump trader items", e);
}
}
} }
/** /**
@@ -60,4 +85,13 @@ public class ShopMod implements WurmServerMod, Initable, Configurable {
return logLevel; return logLevel;
} }
} }
private int getInt(Properties properties, String key, int defaultValue) {
try {
return Integer.parseInt(properties.getProperty(key, Integer.toString(defaultValue)));
} catch (NumberFormatException e) {
logger.log(Level.WARNING, String.format("Invalid int for key '%s', defaulting to %d", key, defaultValue));
return defaultValue;
}
}
} }

View File

@@ -0,0 +1,102 @@
package mod.treestar.shopmod;
import com.wurmonline.server.NoSuchPlayerException;
import com.wurmonline.server.behaviours.Action;
import com.wurmonline.server.behaviours.ActionEntry;
import com.wurmonline.server.creatures.Creature;
import com.wurmonline.server.items.Item;
import com.wurmonline.server.items.ItemList;
import org.gotti.wurmunlimited.modsupport.actions.ActionPerformer;
import org.gotti.wurmunlimited.modsupport.actions.BehaviourProvider;
import org.gotti.wurmunlimited.modsupport.actions.ModActions;
import com.wurmonline.server.questions.ShopQuestion;
import java.util.Collections;
import java.util.List;
/**
* Adds an action to open the shop from supported interactables (village tokens, mailboxes).
*/
public class ShopOpenAction implements ActionPerformer, BehaviourProvider {
private final ShopService shopService;
private final String shopName;
private final boolean allowTokens;
private final boolean allowMailboxes;
private final ActionEntry actionEntry;
public ShopOpenAction(ShopService shopService, String shopName, boolean allowTokens, boolean allowMailboxes) {
this.shopService = shopService;
this.shopName = shopName;
this.allowTokens = allowTokens;
this.allowMailboxes = allowMailboxes;
this.actionEntry = ActionEntry.createEntry(
(short) ModActions.getNextActionId(),
"Open shop",
"opening shop",
new int[] { 23 }
);
ModActions.registerAction(this.actionEntry);
ModActions.registerActionPerformer(this);
ModActions.registerBehaviourProvider(this);
}
@Override
public short getActionId() {
return actionEntry.getNumber();
}
@Override
public boolean action(Action action, Creature performer, Item target, short actionId, float counter) {
if (actionId != getActionId()) {
return false;
}
if (!isValidTarget(target)) {
return true;
}
try {
new ShopQuestion(performer.getWurmId(), shopName, shopService).sendQuestion();
} catch (NoSuchPlayerException e) {
performer.getCommunicator().sendNormalServerMessage("Unable to open shop right now.");
}
return true;
}
@Override
public boolean action(Action action, Creature performer, Creature target, short actionId, float counter) {
return false; // not supported
}
@Override
public boolean action(Action action, Creature performer, Item source, Item target, short actionId, float counter) {
return action(action, performer, target, actionId, counter);
}
@Override
public List<ActionEntry> getBehavioursFor(Creature performer, Item target) {
if (isValidTarget(target)) {
return Collections.singletonList(actionEntry);
}
return null;
}
@Override
public List<ActionEntry> getBehavioursFor(Creature performer, Item source, Item target) {
return getBehavioursFor(performer, target);
}
@Override
public List<ActionEntry> getBehavioursFor(Creature performer, Creature target) {
return null;
}
private boolean isValidTarget(Item target) {
if (target == null) return false;
if (allowTokens && target.getTemplateId() == ItemList.villageToken) {
return true;
}
if (allowMailboxes && target.isMailBox()) {
return true;
}
return false;
}
}

View File

@@ -1,12 +1,21 @@
package mod.treestar.shopmod; package mod.treestar.shopmod;
import com.wurmonline.server.players.Player;
import mod.treestar.shopmod.categoryprovider.ShopCategoryProvider; import mod.treestar.shopmod.categoryprovider.ShopCategoryProvider;
import mod.treestar.shopmod.itemprovider.ShopItemProvider; import mod.treestar.shopmod.itemprovider.ShopItemProvider;
import mod.treestar.shopmod.datamodels.ShopCategory;
import mod.treestar.shopmod.datamodels.ShopItem;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
public class ShopService { public class ShopService {
private static final Logger logger = Logger.getLogger(ShopService.class.getName());
private List<ShopCategoryProvider> categoryProviders = new ArrayList<>(); private List<ShopCategoryProvider> categoryProviders = new ArrayList<>();
private List<ShopItemProvider> itemProviders = new ArrayList<>(); private List<ShopItemProvider> itemProviders = new ArrayList<>();
@@ -20,6 +29,72 @@ public class ShopService {
itemProviders.add(provider); itemProviders.add(provider);
} }
public List<ShopCategory> getCategories() {
List<ShopCategory> categories = new ArrayList<>();
for (ShopCategoryProvider provider : categoryProviders) {
try {
List<ShopCategory> provided = provider.getCategories();
if (provided != null) {
categories.addAll(provided);
}
} catch (Exception e) {
logger.log(Level.WARNING, "Category provider threw an exception", e);
}
}
return categories;
}
public List<ShopItem> getItems() {
List<ShopItem> items = new ArrayList<>();
for (ShopItemProvider provider : itemProviders) {
try {
List<ShopItem> provided = provider.getItems();
if (provided != null) {
items.addAll(provided);
}
} catch (Exception e) {
logger.log(Level.WARNING, "Item provider threw an exception", e);
}
}
return items;
}
public ShopItem getItemById(int id) {
return getItems().stream().filter(i -> i.getId() == id).findFirst().orElse(null);
}
public PurchaseResult purchaseItem(Player player, int itemId) {
ShopItem item = getItemById(itemId);
if (item == null) {
return PurchaseResult.failure("Item not found.");
}
if (item.getCurrency() == null) {
return PurchaseResult.failure("Item has no currency configured.");
}
if (item.getPurchaseHandler() == null) {
return PurchaseResult.failure("Item has no purchase handler configured.");
}
try {
if (!item.getCurrency().canPlayerAfford(player)) {
return PurchaseResult.failure("You cannot afford this item.");
}
boolean charged = item.getCurrency().chargePlayer(player);
if (!charged) {
return PurchaseResult.failure("Charging failed; purchase canceled.");
}
} catch (Exception e) {
logger.log(Level.WARNING, "Currency check/charge failed for item " + itemId + " for player " + player.getName(), e);
return PurchaseResult.failure("Payment failed; purchase canceled.");
}
try {
item.getPurchaseHandler().onPurchase(player);
return PurchaseResult.success("Purchase successful.");
} catch (Exception e) {
logger.log(Level.WARNING, "Purchase handler failed for item " + itemId + " for player " + player.getName(), e);
return PurchaseResult.failure("An error occurred while delivering the item.");
}
}
public static ShopService getInstance() { public static ShopService getInstance() {
if(instance == null) { if(instance == null) {
@@ -34,4 +109,30 @@ public class ShopService {
public static ShopService create() { public static ShopService create() {
return new ShopService(); return new ShopService();
} }
public static class PurchaseResult {
private final boolean success;
private final String message;
private PurchaseResult(boolean success, String message) {
this.success = success;
this.message = message;
}
public static PurchaseResult success(String message) {
return new PurchaseResult(true, message);
}
public static PurchaseResult failure(String message) {
return new PurchaseResult(false, message);
}
public boolean isSuccess() {
return success;
}
public String getMessage() {
return message;
}
}
} }

View File

@@ -1,18 +1,25 @@
package mod.treestar.shopmod.categoryprovider; package mod.treestar.shopmod.categoryprovider;
import com.wurmonline.server.support.JSONArray; import com.wurmonline.server.support.JSONArray;
import com.wurmonline.server.support.JSONObject;
import com.wurmonline.server.support.JSONTokener; import com.wurmonline.server.support.JSONTokener;
import mod.treestar.shopmod.datamodels.ShopCategory; import mod.treestar.shopmod.datamodels.ShopCategory;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
public class JsonShopCategoryProvider implements ShopCategoryProvider { public class JsonShopCategoryProvider implements ShopCategoryProvider {
private static final Logger logger = Logger.getLogger(JsonShopCategoryProvider.class.getName());
private final String categoryJsonPath; private final String categoryJsonPath;
private long lastModified = -1L;
private List<ShopCategory> cachedCategories = Collections.emptyList();
public JsonShopCategoryProvider(String categoryJsonPath) { public JsonShopCategoryProvider(String categoryJsonPath) {
this.categoryJsonPath = categoryJsonPath; this.categoryJsonPath = categoryJsonPath;
@@ -20,14 +27,48 @@ public class JsonShopCategoryProvider implements ShopCategoryProvider {
@Override @Override
public List<ShopCategory> getCategories() { public List<ShopCategory> getCategories() {
return Collections.emptyList(); try {
maybeReload();
} catch (IOException e) {
logger.log(Level.WARNING, "Failed loading categories from " + categoryJsonPath, e);
}
return cachedCategories;
} }
private void loadCategories() throws IOException { private void maybeReload() throws IOException {
File file = new File(categoryJsonPath); File file = new File(categoryJsonPath);
if (!file.exists()) {
logger.log(Level.WARNING, "Category file not found at " + categoryJsonPath);
cachedCategories = Collections.emptyList();
return;
}
if (file.lastModified() == lastModified) {
return; // up to date
}
try (FileInputStream f = new FileInputStream(file)) { try (FileInputStream f = new FileInputStream(file)) {
JSONTokener tokenizer = new JSONTokener(f); JSONTokener tokenizer = new JSONTokener(f);
JSONArray typeArray = new JSONArray(tokenizer); JSONArray typeArray = new JSONArray(tokenizer);
cachedCategories = parseCategories(typeArray);
lastModified = file.lastModified();
} catch (Exception e) {
logger.log(Level.WARNING, "Failed to parse categories JSON; keeping previous categories", e);
} }
} }
private List<ShopCategory> parseCategories(JSONArray typeArray) {
List<ShopCategory> categories = new ArrayList<>();
for (int i = 0; i < typeArray.length(); i++) {
JSONObject obj = typeArray.getJSONObject(i);
if (!obj.has("id") || !obj.has("name")) {
logger.log(Level.WARNING, "Skipping category missing required fields at index " + i);
continue;
}
ShopCategory category = new ShopCategory();
category.setId(obj.getInt("id"));
category.setName(obj.getString("name"));
category.setDescription(obj.optString("description", ""));
categories.add(category);
}
return categories;
}
} }

View File

@@ -72,4 +72,15 @@ public class ShopItem {
public void setCurrency(ShopCurrency currency) { public void setCurrency(ShopCurrency currency) {
this.currency = currency; this.currency = currency;
} }
/**
* Human-readable price for UI/BML display.
*/
public String getPriceDisplay() {
return currency != null ? currency.getDisplay() : "Unavailable";
}
public boolean hasCurrency() {
return currency != null;
}
} }

View File

@@ -1,20 +1,43 @@
package mod.treestar.shopmod.itemprovider; package mod.treestar.shopmod.itemprovider;
import com.wurmonline.server.support.JSONArray; import com.wurmonline.server.support.JSONArray;
import com.wurmonline.server.support.JSONObject;
import com.wurmonline.server.support.JSONTokener; import com.wurmonline.server.support.JSONTokener;
import mod.treestar.shopmod.currencies.ShopCurrency;
import mod.treestar.shopmod.datamodels.ShopItem; import mod.treestar.shopmod.datamodels.ShopItem;
import mod.treestar.shopmod.purchasehandlers.ShopItemPurchaseEffect;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
/** /**
* Loads shop items from a JSON file. Parsing will be completed in a later step. * Loads shop items from a JSON file. Expects an array of objects with:
* id (int), name (string), description (string, optional), image (string, optional),
* categoryId (int), currency (object with type and amount), handler (object describing purchase effect).
*/ */
public class JsonShopItemProvider implements ShopItemProvider { public class JsonShopItemProvider implements ShopItemProvider {
private static final Logger logger = Logger.getLogger(JsonShopItemProvider.class.getName());
private static final String CURRENCIES_PACKAGE = "mod.treestar.shopmod.currencies.";
private static final String HANDLERS_PACKAGE = "mod.treestar.shopmod.purchasehandlers.";
private final String itemJsonPath; private final String itemJsonPath;
private long lastModified = -1L;
private List<ShopItem> cachedItems = Collections.emptyList();
// Cache for class lookups to avoid repeated reflection
private final Map<String, Class<?>> currencyClassCache = new HashMap<>();
private final Map<String, Class<?>> handlerClassCache = new HashMap<>();
public JsonShopItemProvider(String itemJsonPath) { public JsonShopItemProvider(String itemJsonPath) {
this.itemJsonPath = itemJsonPath; this.itemJsonPath = itemJsonPath;
@@ -22,14 +45,186 @@ public class JsonShopItemProvider implements ShopItemProvider {
@Override @Override
public List<ShopItem> getItems() { public List<ShopItem> getItems() {
return Collections.emptyList(); try {
maybeReload();
} catch (IOException e) {
logger.log(Level.WARNING, "Failed loading items from " + itemJsonPath, e);
}
return cachedItems;
} }
private void loadItems() throws IOException { private void maybeReload() throws IOException {
File file = new File(itemJsonPath); File file = new File(itemJsonPath);
if (!file.exists()) {
logger.log(Level.WARNING, "Item file not found at " + itemJsonPath);
cachedItems = Collections.emptyList();
return;
}
if (file.lastModified() == lastModified) {
return; // up to date
}
try (FileInputStream f = new FileInputStream(file)) { try (FileInputStream f = new FileInputStream(file)) {
JSONTokener tokenizer = new JSONTokener(f); JSONTokener tokenizer = new JSONTokener(f);
JSONArray typeArray = new JSONArray(tokenizer); JSONArray typeArray = new JSONArray(tokenizer);
cachedItems = parseItems(typeArray);
lastModified = file.lastModified();
} catch (Exception e) {
logger.log(Level.WARNING, "Failed to parse items JSON; keeping previous items", e);
} }
} }
private List<ShopItem> parseItems(JSONArray typeArray) {
List<ShopItem> items = new ArrayList<>();
for (int i = 0; i < typeArray.length(); i++) {
JSONObject obj = typeArray.getJSONObject(i);
if (!obj.has("id") || !obj.has("name") || !obj.has("categoryId")) {
logger.log(Level.WARNING, "Skipping item missing required fields at index " + i);
continue;
}
ShopItem item = new ShopItem();
item.setId(obj.getInt("id"));
item.setName(obj.getString("name"));
item.setDescription(obj.optString("description", ""));
item.setImage(obj.optString("image", ""));
item.setCategoryId(obj.getInt("categoryId"));
ShopCurrency currency = parseCurrency(obj.optJSONObject("currency"), i);
item.setCurrency(currency);
ShopItemPurchaseEffect handler = parsePurchaseHandler(obj.optJSONObject("handler"), i);
item.setPurchaseHandler(handler);
if (currency == null || handler == null) {
logger.log(Level.WARNING, "Skipping item at index " + i + " due to missing currency or handler");
continue;
}
items.add(item);
}
return items;
}
private ShopCurrency parseCurrency(JSONObject currencyObj, int index) {
if (currencyObj == null) {
logger.log(Level.WARNING, "Item at index " + index + " missing currency; item will be unusable");
return null;
}
String type = currencyObj.optString("type", "");
if (type.isEmpty()) {
logger.log(Level.WARNING, "Item at index " + index + " missing currency type");
return null;
}
try {
Class<?> clazz = currencyClassCache.computeIfAbsent(type, t -> {
try {
return Class.forName(CURRENCIES_PACKAGE + t);
} catch (ClassNotFoundException e) {
return null;
}
});
if (clazz == null || !ShopCurrency.class.isAssignableFrom(clazz)) {
logger.log(Level.WARNING, "Unknown or invalid currency type '" + type + "' for item at index " + index);
return null;
}
Object instance = clazz.getDeclaredConstructor().newInstance();
applyJsonFields(instance, currencyObj, index);
return (ShopCurrency) instance;
} catch (Exception e) {
logger.log(Level.WARNING, "Failed to instantiate currency '" + type + "' for item at index " + index, e);
return null;
}
}
private ShopItemPurchaseEffect parsePurchaseHandler(JSONObject handlerObj, int index) {
if (handlerObj == null) {
logger.log(Level.WARNING, "Item at index " + index + " missing handler; item will be unusable");
return null;
}
String type = handlerObj.optString("type", "");
if (type.isEmpty()) {
logger.log(Level.WARNING, "Item at index " + index + " missing handler type");
return null;
}
try {
Class<?> clazz = handlerClassCache.computeIfAbsent(type, t -> {
try {
return Class.forName(HANDLERS_PACKAGE + t);
} catch (ClassNotFoundException e) {
return null;
}
});
if (clazz == null || !ShopItemPurchaseEffect.class.isAssignableFrom(clazz)) {
logger.log(Level.WARNING, "Unknown or invalid handler type '" + type + "' for item at index " + index);
return null;
}
Object instance = clazz.getDeclaredConstructor().newInstance();
applyJsonFields(instance, handlerObj, index);
return (ShopItemPurchaseEffect) instance;
} catch (Exception e) {
logger.log(Level.WARNING, "Failed to instantiate handler '" + type + "' for item at index " + index, e);
return null;
}
}
@SuppressWarnings("unchecked")
private void applyJsonFields(Object instance, JSONObject json, int index) {
Class<?> clazz = instance.getClass();
Iterator<String> keys = json.keys();
while (keys.hasNext()) {
String key = keys.next();
if ("type".equals(key)) {
continue; // skip the type field, it's used for class lookup
}
String setterName = "set" + Character.toUpperCase(key.charAt(0)) + key.substring(1);
Object value = json.get(key);
Method setter = findSetter(clazz, setterName);
if (setter == null) {
logger.log(Level.FINE, "No setter '" + setterName + "' found on " + clazz.getSimpleName() + " for item at index " + index);
continue;
}
try {
Class<?> paramType = setter.getParameterTypes()[0];
Object convertedValue = convertValue(value, paramType);
if (convertedValue != null) {
setter.invoke(instance, convertedValue);
}
} catch (Exception e) {
logger.log(Level.WARNING, "Failed to set field '" + key + "' on " + clazz.getSimpleName() + " for item at index " + index, e);
}
}
}
private Method findSetter(Class<?> clazz, String setterName) {
for (Method method : clazz.getMethods()) {
if (method.getName().equals(setterName) && method.getParameterCount() == 1) {
return method;
}
}
return null;
}
private Object convertValue(Object value, Class<?> targetType) {
if (value == null) {
return null;
}
// Handle primitive types and their wrappers
if (targetType == int.class || targetType == Integer.class) {
return ((Number) value).intValue();
} else if (targetType == long.class || targetType == Long.class) {
return ((Number) value).longValue();
} else if (targetType == float.class || targetType == Float.class) {
return ((Number) value).floatValue();
} else if (targetType == double.class || targetType == Double.class) {
return ((Number) value).doubleValue();
} else if (targetType == byte.class || targetType == Byte.class) {
return ((Number) value).byteValue();
} else if (targetType == short.class || targetType == Short.class) {
return ((Number) value).shortValue();
} else if (targetType == boolean.class || targetType == Boolean.class) {
return value;
} else if (targetType == String.class) {
return value.toString();
}
// If no conversion needed or type matches
if (targetType.isInstance(value)) {
return value;
}
return null;
}
} }

View File

@@ -5,8 +5,6 @@ import com.wurmonline.server.Server;
import com.wurmonline.server.items.*; import com.wurmonline.server.items.*;
import com.wurmonline.server.players.Player; import com.wurmonline.server.players.Player;
import java.util.Random;
/** /**
* Simple item purchase effect that gives a player a specific item on a successful purchase. * Simple item purchase effect that gives a player a specific item on a successful purchase.
* @see ShopItemPurchaseEffect * @see ShopItemPurchaseEffect
@@ -17,13 +15,30 @@ public class ShopWurmItemPurchaseEffect implements ShopItemPurchaseEffect {
private boolean randomQl; private boolean randomQl;
private byte rarity; private byte rarity;
public void setItemTemplateId(int itemTemplateId) {
this.itemTemplateId = itemTemplateId;
}
public void setQl(float ql) {
this.ql = ql;
}
public void setRandomQl(boolean randomQl) {
this.randomQl = randomQl;
}
public void setRarity(byte rarity) {
this.rarity = rarity;
}
@Override @Override
public void onPurchase(Player player) { public void onPurchase(Player player) {
try { try {
float thisPurchaseQl = this.ql;
if(randomQl) { if(randomQl) {
ql = Server.rand.nextInt(99) + 1; thisPurchaseQl = Server.rand.nextInt(99) + 1;
} }
Item item = ItemFactory.createItem(itemTemplateId, ql, (byte) 0, (byte) rarity, null); Item item = ItemFactory.createItem(itemTemplateId, thisPurchaseQl, (byte) 0, (byte) rarity, null);
player.getInventory().insertItem(item); player.getInventory().insertItem(item);
ItemTemplate template = ItemTemplateFactory.getInstance().getTemplate(itemTemplateId); ItemTemplate template = ItemTemplateFactory.getInstance().getTemplate(itemTemplateId);
player.sendSystemMessage(String.format("You receive a %s.", template.getName())); player.sendSystemMessage(String.format("You receive a %s.", template.getName()));

View File

@@ -0,0 +1,263 @@
package mod.treestar.shopmod.util;
import java.util.logging.Level;
import java.util.logging.Logger;
public class BmlForm {
private static Logger logger = Logger.getLogger(BmlForm.class.getName());
private final StringBuffer buf = new StringBuffer();
private int openBorders = 0;
private int openCenters = 0;
private int openVarrays = 0;
private int openScrolls = 0;
private int openHarrays = 0;
private int openTrees = 0;
private int openRows = 0;
private int openColumns = 0;
private int openTables = 0;
private int indentNum = 0;
private boolean beautify = true;
private boolean closeDefault = false;
public BmlForm() {
}
public BmlForm(String formTitle) {
this.addDefaultHeader(formTitle);
}
public void addDefaultHeader(String formTitle) {
if (this.closeDefault) {
return;
}
this.beginBorder();
this.beginCenter();
this.addBoldText(formTitle, new String[0]);
this.endCenter();
this.beginScroll();
this.beginVerticalFlow();
this.closeDefault = true;
}
public void beginBorder() {
this.buf.append(this.indent("border{"));
++this.indentNum;
++this.openBorders;
}
public void endBorder() {
--this.indentNum;
this.buf.append(this.indent("}"));
--this.openBorders;
}
public void beginCenter() {
this.buf.append(this.indent("center{"));
++this.indentNum;
++this.openCenters;
}
public void endCenter() {
--this.indentNum;
this.buf.append(this.indent("};null;"));
--this.openCenters;
}
public void beginVerticalFlow() {
this.buf.append(this.indent("varray{rescale=\"true\";"));
++this.indentNum;
++this.openVarrays;
}
public void endVerticalFlow() {
--this.indentNum;
this.buf.append(this.indent("}"));
--this.openVarrays;
}
public void beginScroll() {
this.buf.append(this.indent("scroll{vertical=\"true\";horizontal=\"false\";"));
++this.indentNum;
++this.openScrolls;
}
public void endScroll() {
--this.indentNum;
this.buf.append(this.indent("};null;null;"));
--this.openScrolls;
}
public void beginHorizontalFlow() {
this.buf.append(this.indent("harray {"));
++this.indentNum;
++this.openHarrays;
}
public void endHorizontalFlow() {
--this.indentNum;
this.buf.append(this.indent("}"));
--this.openHarrays;
}
public void beginTable(int rowCount, String[] columns) {
this.buf.append(this.indent("table {rows=\"" + rowCount + "\"; cols=\"" + columns.length + "\";"));
++this.indentNum;
String[] arrstring = columns;
int n = arrstring.length;
int n2 = 0;
while (n2 < n) {
String c = arrstring[n2];
this.addLabel(c);
++n2;
}
--this.indentNum;
++this.indentNum;
++this.openTables;
}
public void endTable() {
--this.indentNum;
this.buf.append(this.indent("}"));
--this.openTables;
}
public void addHidden(String name, String val) {
this.buf.append(this.indent("passthrough{id=\"" + name + "\";text=\"" + val + "\"}"));
}
private String indent(String s) {
return this.beautify ? String.valueOf(this.getIndentation()) + s + "\r\n" : s;
}
private String getIndentation() {
if (this.indentNum > 0) {
return "\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t".substring(0, this.indentNum);
}
return "";
}
public void addRaw(String s) {
this.buf.append(s);
}
public void addImage(String url, int height, int width) {
this.addImage(url, height, width, "");
}
public void addImage(String url, int height, int width, String tooltip) {
this.buf.append("image{src=\"");
this.buf.append(url);
this.buf.append("\";size=\"");
this.buf.append(String.valueOf(height) + "," + width);
this.buf.append("\";text=\"" + tooltip + "\"}");
}
public void addLabel(String text) {
this.buf.append("label{text='" + text + "'};");
}
public void addInput(String id, int maxChars, String defaultText) {
this.buf.append("input{id='" + id + "';maxchars='" + maxChars + "';text=\"" + defaultText + "\"};");
}
public void addColoredText(String text, int r, int g, int b, String ... args){
this.addText(text, "", r, g, b, args);
}
public void addBoldText(String text, String ... args) {
this.addText(text, "bold", args);
}
public void addBoldColoredText(String text, int r, int g, int b, String ... args){
this.addText(text, "bold", r, g, b, args);
}
public void addText(String text, String ... args) {
this.addText(text, "", args);
}
private void addText(String text, String type, String ... args){
this.addText(text, type, -10, -10, -10, args);
}
private void addText(String text, String type, int r, int g, int b, String ... args) {
String[] lines;
String[] arrstring = lines = text.split("\n");
int n = arrstring.length;
int n2 = 0;
while (n2 < n) {
String l = arrstring[n2];
if (this.beautify) {
this.buf.append(this.getIndentation());
}
this.buf.append("text{");
if (!type.equals("")) {
this.buf.append("type='").append(type).append("';");
}
if(r >= 0 && g >= 0 && b >= 0 && r <= 255 && g <= 255 && b <= 255){
this.buf.append("color='").append(r).append(",").append(g).append(",").append(b).append("';");
}
this.buf.append("text=\"");
this.buf.append(String.format(l, (Object[]) args));
this.buf.append("\"}");
if (this.beautify) {
this.buf.append("\r\n");
}
++n2;
}
}
public void addButton(String name, String id) {
this.buf.append(this.indent("button{text=' " + name + " ';id='" + id + "'}"));
}
private String escape(String val) {
if (val == null) {
return "";
}
return val.replace("\"", "''");
}
public void beginTree(String id, int cols, String headers) {
this.buf.append(this.indent("tree{id=\"" + id + "\";cols=\"" + cols + "\";showheader=\"true\";" + headers));
++this.indentNum;
++this.openTrees;
}
public void endTree() {
--this.indentNum;
this.buf.append(this.indent("}"));
--this.openTrees;
}
public void addTreeRow(String id, String name, String hover, String... cells) {
StringBuilder row = new StringBuilder();
row.append("row{id=\"").append(id).append("\";");
if (hover != null && !hover.isEmpty()) {
row.append("hover=\"").append(escape(hover)).append("\";");
}
row.append("name=\"").append(name).append("\";rarity=\"0\";children=\"0\";");
for(int i=0;i<cells.length;i++) {
String c = cells[i];
row.append("col{").append(c).append("}");
if(i != cells.length-1) {
row.append(";");
}
}
row.append("}");
this.buf.append(this.indent(row.toString()));
}
public String toString() {
if (this.closeDefault) {
this.endVerticalFlow();
this.endScroll();
this.endBorder();
this.closeDefault = false;
}
if (this.openCenters != 0 || this.openVarrays != 0 || this.openScrolls != 0 || this.openHarrays != 0 || this.openBorders != 0 || this.openTrees != 0 || this.openRows != 0 || this.openColumns != 0 || this.openTables != 0) {
logger.log(Level.SEVERE, "While finalizing BML unclosed (or too many closed) blocks were found (this will likely mean the BML will not work!): center: " + this.openCenters + " vert-flows: " + this.openVarrays + " scroll: " + this.openScrolls + " horiz-flows: " + this.openHarrays + " border: " + this.openBorders + " trees: " + this.openTrees + " rows: " + this.openRows + " columns: " + this.openColumns + " tables: " + this.openTables);
}
return this.buf.toString();
}
}

View File

@@ -0,0 +1,89 @@
package mod.treestar.shopmod.util;
import com.wurmonline.server.Items;
import com.wurmonline.server.creatures.Creature;
import com.wurmonline.server.creatures.Creatures;
import com.wurmonline.server.items.Item;
import com.wurmonline.server.support.JSONArray;
import com.wurmonline.server.support.JSONObject;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Utility to dump the current contents of all traders into a shop_items.json compatible array.
*/
public class TraderItemExporter {
private static final Logger logger = Logger.getLogger(TraderItemExporter.class.getName());
public static void dumpTraderItems(String outputPath, int categoryId) {
JSONArray out = new JSONArray();
AtomicInteger id = new AtomicInteger(1);
Set<Integer> seenTemplates = new HashSet<>();
for (Creature c : Creatures.getInstance().getCreatures()) {
if (c == null || !c.isTrader()) {
continue;
}
logger.log(Level.INFO, "Trader found");
try {
for (Item it : c.getAllItems()) {
if (it.isCoin()) {
continue; // skip currency
}
if(it.isBodyPart()) {
continue; // Skip body parts
}
if(seenTemplates.contains(it.getTemplateId())) {
continue;
}
seenTemplates.add(it.getTemplateId());
JSONObject obj = new JSONObject();
obj.put("id", id.getAndIncrement());
obj.put("categoryId", categoryId);
obj.put("name", it.getName());
obj.put("description", it.getTemplate().getDescriptionLong());
obj.put("image", "");
JSONObject currency = new JSONObject();
currency.put("type", "WurmBankCurrency");
currency.put("ironAmount", it.getValue());
obj.put("currency", currency);
JSONObject handler = new JSONObject();
handler.put("type", "ShopWurmItemPurchaseEffect");
handler.put("itemTemplateId", it.getTemplateId());
handler.put("ql", it.getQualityLevel());
handler.put("rarity", (int) it.getRarity());
obj.put("handler", handler);
out.put(obj);
}
} catch (Exception e) {
logger.log(Level.WARNING, "Failed exporting items for trader " + c.getName(), e);
}
}
writeToFile(out, outputPath);
}
private static void writeToFile(JSONArray out, String outputPath) {
Path path = Paths.get(outputPath);
try {
if (path.getParent() != null) {
Files.createDirectories(path.getParent());
}
try (FileWriter fw = new FileWriter(path.toFile())) {
fw.write(out.toString(2));
}
logger.info("Dumped " + out.length() + " trader items to " + path.toAbsolutePath());
} catch (IOException e) {
logger.log(Level.WARNING, "Failed writing trader dump to " + outputPath, e);
}
}
}