This commit is contained in:
commit
07eb6dc053
318 changed files with 339857 additions and 0 deletions
|
|
@ -0,0 +1,103 @@
|
|||
package fr.shinuwa.counterofshinuwa.test;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.*;
|
||||
import fr.shinuwa.counterofshinuwa.block.ShopInventoryBlockEntity;
|
||||
import fr.shinuwa.counterofshinuwa.client.ShopScreen;
|
||||
import fr.shinuwa.counterofshinuwa.core.*;
|
||||
import fr.shinuwa.counterofshinuwa.gameplay.ShopGameplayMode;
|
||||
import java.nio.file.*;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.Screenshot;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.neoforged.api.distmarker.Dist;
|
||||
import net.neoforged.fml.common.Mod;
|
||||
import net.neoforged.neoforge.client.event.ClientTickEvent;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
|
||||
/** Opt-in graphical smoke test, never packaged in the released mod. */
|
||||
@Mod(value = "counter_of_shinuwa_client_smoke", dist = Dist.CLIENT)
|
||||
public final class ClientSmoke {
|
||||
private boolean scheduled;
|
||||
private int screenTicks;
|
||||
|
||||
public ClientSmoke() {
|
||||
NeoForge.EVENT_BUS.addListener(this::tick);
|
||||
}
|
||||
|
||||
private void tick(ClientTickEvent.Post event) {
|
||||
var mc = Minecraft.getInstance();
|
||||
if (mc.player == null || mc.getSingleplayerServer() == null) return;
|
||||
if (!scheduled) {
|
||||
scheduled = true;
|
||||
var server = mc.getSingleplayerServer();
|
||||
var id = mc.player.getUUID();
|
||||
server.execute(
|
||||
() -> {
|
||||
var p = server.getPlayerList().getPlayer(id);
|
||||
var level = p.serverLevel();
|
||||
for (int x = 0; x < 16; x++)
|
||||
for (int z = 0; z < 16; z++)
|
||||
level.setBlockAndUpdate(new BlockPos(x, -60, z), Blocks.STONE.defaultBlockState());
|
||||
var manager = ShopManager.get(server);
|
||||
manager.closeAll();
|
||||
manager.data.shops.clear();
|
||||
BlockPos counter = new BlockPos(8, -59, 5), storage = new BlockPos(4, -59, 3);
|
||||
level.setBlockAndUpdate(counter, CounterOfShinuwa.COUNTER.get().defaultBlockState());
|
||||
level.setBlockAndUpdate(storage, CounterOfShinuwa.STORAGE.get().defaultBlockState());
|
||||
var shop = manager.create(p, counter);
|
||||
shop.cornerA = new BlockPos(1, -59, 1);
|
||||
shop.cornerB = new BlockPos(14, -56, 14);
|
||||
shop.entry = new BlockPos(2, -59, 5);
|
||||
shop.exit = new BlockPos(2, -59, 7);
|
||||
shop.storages.add(storage);
|
||||
for (var pos : java.util.List.of(shop.cornerA, shop.cornerB))
|
||||
level.setBlockAndUpdate(pos, CounterOfShinuwa.MARKER.get().defaultBlockState());
|
||||
for (var pos : java.util.List.of(shop.entry, shop.exit))
|
||||
level.setBlockAndUpdate(pos, CounterOfShinuwa.ACCESS.get().defaultBlockState());
|
||||
for (int x = 3; x <= 7; x++)
|
||||
level.setBlockAndUpdate(
|
||||
new BlockPos(x, -59, 5), Blocks.BLUE_CARPET.defaultBlockState());
|
||||
level.setBlockAndUpdate(new BlockPos(8, -59, 6), Blocks.RED_CARPET.defaultBlockState());
|
||||
for (int x = 3; x <= 8; x++)
|
||||
level.setBlockAndUpdate(
|
||||
new BlockPos(x, -59, 7), Blocks.RED_CARPET.defaultBlockState());
|
||||
level.setBlockAndUpdate(
|
||||
new BlockPos(6, -59, 3), Blocks.CRAFTING_TABLE.defaultBlockState());
|
||||
var be = (ShopInventoryBlockEntity) level.getBlockEntity(counter);
|
||||
be.owner = id;
|
||||
var raw = (ShopInventoryBlockEntity) level.getBlockEntity(storage);
|
||||
raw.owner = id;
|
||||
raw.setItem(0, new ItemStack(Items.OAK_LOG, 32));
|
||||
p.teleportTo(6.5, -59, 5.5);
|
||||
manager.data.setDirty();
|
||||
String result = manager.open(p, shop, ShopGameplayMode.CHILL);
|
||||
if (!result.equals("opened"))
|
||||
throw new IllegalStateException("Client smoke shop: " + result);
|
||||
manager.session(shop.id).nextArrival = level.getGameTime();
|
||||
p.openMenu(
|
||||
be,
|
||||
buf -> {
|
||||
buf.writeBlockPos(counter);
|
||||
buf.writeBoolean(false);
|
||||
buf.writeUUID(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (mc.screen instanceof ShopScreen screen && screen.getMenu().view.contains("Order")) {
|
||||
if (++screenTicks == 40) {
|
||||
try (var image = Screenshot.takeScreenshot(mc.getMainRenderTarget())) {
|
||||
image.writeToFile(Path.of("counter-smoke.png"));
|
||||
Files.writeString(
|
||||
Path.of("counter-smoke.ok"),
|
||||
"Client connected, shop menu synchronized, Easy NPC reached counter and generated an"
|
||||
+ " order.\n");
|
||||
} catch (java.io.IOException ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
mc.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/clientSmoke/resources/META-INF/neoforge.mods.toml
Normal file
7
src/clientSmoke/resources/META-INF/neoforge.mods.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
modLoader="javafml"
|
||||
loaderVersion="[1,)"
|
||||
license="All Rights Reserved"
|
||||
[[mods]]
|
||||
modId="counter_of_shinuwa_client_smoke"
|
||||
version="1.0"
|
||||
displayName="Counter client smoke test"
|
||||
|
|
@ -0,0 +1,656 @@
|
|||
package fr.shinuwa.counterofshinuwa.test;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.*;
|
||||
import fr.shinuwa.counterofshinuwa.block.ShopInventoryBlockEntity;
|
||||
import fr.shinuwa.counterofshinuwa.core.*;
|
||||
import fr.shinuwa.counterofshinuwa.gameplay.*;
|
||||
import fr.shinuwa.counterofshinuwa.integration.easynpc.EasyNpcAdapter;
|
||||
import fr.shinuwa.counterofshinuwa.menu.ShopMenu;
|
||||
import fr.shinuwa.counterofshinuwa.production.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.gametest.framework.*;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.SimpleContainer;
|
||||
import net.minecraft.world.inventory.*;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity;
|
||||
import net.neoforged.fml.common.Mod;
|
||||
import net.neoforged.neoforge.gametest.*;
|
||||
|
||||
@Mod("counter_of_shinuwa_tests")
|
||||
@GameTestHolder(CounterOfShinuwa.MODID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class ShopGameTests {
|
||||
public ShopGameTests() {
|
||||
TestProductionIntegration.register();
|
||||
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(
|
||||
(net.neoforged.neoforge.event.server.ServerStartingEvent event) -> {
|
||||
event.getServer().getPlayerList().setViewDistance(2);
|
||||
event.getServer().getPlayerList().setSimulationDistance(2);
|
||||
});
|
||||
}
|
||||
|
||||
private record Fixture(
|
||||
ServerPlayer player,
|
||||
ShopManager manager,
|
||||
Shop shop,
|
||||
ShopSession session,
|
||||
BlockPos crafting,
|
||||
BlockPos furnace) {
|
||||
void finish(GameTestHelper helper) {
|
||||
var active = manager.session(shop.id);
|
||||
if (active != null) manager.close(active, false);
|
||||
manager.data.shops.remove(shop.id);
|
||||
manager.data.setDirty();
|
||||
player.server.getPlayerList().remove(player);
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
|
||||
private static Fixture setup(GameTestHelper h) {
|
||||
for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) h.setBlock(x, 0, z, Blocks.STONE);
|
||||
h.setBlock(1, 1, 1, CounterOfShinuwa.MARKER.get());
|
||||
h.setBlock(14, 4, 14, CounterOfShinuwa.MARKER.get());
|
||||
h.setBlock(8, 1, 5, CounterOfShinuwa.COUNTER.get());
|
||||
h.setBlock(4, 1, 3, CounterOfShinuwa.STORAGE.get());
|
||||
h.setBlock(2, 1, 5, CounterOfShinuwa.ACCESS.get());
|
||||
h.setBlock(2, 1, 7, CounterOfShinuwa.ACCESS.get());
|
||||
for (int x = 3; x <= 7; x++) h.setBlock(x, 1, 5, Blocks.BLUE_CARPET);
|
||||
h.setBlock(8, 1, 6, Blocks.RED_CARPET);
|
||||
for (int x = 3; x <= 8; x++) h.setBlock(x, 1, 7, Blocks.RED_CARPET);
|
||||
h.setBlock(6, 1, 3, Blocks.CRAFTING_TABLE);
|
||||
h.setBlock(8, 1, 3, Blocks.FURNACE);
|
||||
ServerPlayer p = h.makeMockServerPlayerInLevel();
|
||||
BlockPos stand = h.absolutePos(new BlockPos(5, 1, 4));
|
||||
p.setPos(stand.getX() + .5, stand.getY(), stand.getZ() + .5);
|
||||
var manager = ShopManager.get(h.getLevel().getServer());
|
||||
Shop shop = manager.create(p, h.absolutePos(new BlockPos(8, 1, 5)));
|
||||
shop.cornerA = h.absolutePos(new BlockPos(1, 1, 1));
|
||||
shop.cornerB = h.absolutePos(new BlockPos(14, 4, 14));
|
||||
shop.entry = h.absolutePos(new BlockPos(2, 1, 5));
|
||||
shop.exit = h.absolutePos(new BlockPos(2, 1, 7));
|
||||
shop.storages.add(h.absolutePos(new BlockPos(4, 1, 3)));
|
||||
((ShopInventoryBlockEntity) h.getLevel().getBlockEntity(shop.counter)).owner = p.getUUID();
|
||||
var storage =
|
||||
(ShopInventoryBlockEntity) h.getLevel().getBlockEntity(shop.storages.iterator().next());
|
||||
storage.owner = p.getUUID();
|
||||
storage.setItem(0, new ItemStack(Items.OAK_LOG, 32));
|
||||
storage.setItem(1, new ItemStack(Items.RAW_IRON, 16));
|
||||
storage.setItem(2, new ItemStack(Items.COAL, 8));
|
||||
var report = manager.verify(shop);
|
||||
h.assertTrue(report.valid(), "Shop invalid: " + report.errors());
|
||||
String opened = manager.open(p, shop, ShopGameplayMode.CHILL);
|
||||
h.assertTrue(opened.equals("opened"), "Open failed: " + opened);
|
||||
var s = manager.session(shop.id);
|
||||
s.nextArrival = Long.MAX_VALUE;
|
||||
return new Fixture(
|
||||
p,
|
||||
manager,
|
||||
shop,
|
||||
s,
|
||||
h.absolutePos(new BlockPos(6, 1, 3)),
|
||||
h.absolutePos(new BlockPos(8, 1, 3)));
|
||||
}
|
||||
|
||||
private static ItemStack stamped(Fixture f, Item item, int n) {
|
||||
var stack = new ItemStack(item, n);
|
||||
stack.set(
|
||||
CounterOfShinuwa.PRODUCTION.get(),
|
||||
new ShopProductionData(
|
||||
f.shop.id,
|
||||
f.session.id,
|
||||
f.player.getUUID(),
|
||||
"counter_of_shinuwa:manual",
|
||||
"counter_of_shinuwa:crafting"));
|
||||
return stack;
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void persistenceAndModeIsolation(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
var reloaded = Shop.load(f.shop.save());
|
||||
h.assertTrue(
|
||||
reloaded.owner.equals(f.shop.owner)
|
||||
&& reloaded.counter.equals(f.shop.counter)
|
||||
&& reloaded.storages.equals(f.shop.storages),
|
||||
"Shop persistence");
|
||||
ItemStack old = stamped(f, Items.OAK_PLANKS, 4);
|
||||
f.manager.data.reputation(f.shop.owner, 500);
|
||||
f.manager.close(f.session, false);
|
||||
h.assertTrue(
|
||||
f.manager.open(f.player, f.shop, ShopGameplayMode.STANDARD).equals("opened"),
|
||||
"Switch mode");
|
||||
var next = f.manager.session(f.shop.id);
|
||||
h.assertTrue(!ShopProductionTracker.valid(next, old), "Old session accepted");
|
||||
h.assertTrue(
|
||||
f.manager.reputation(f.shop.owner) == 500, "Reputation changed during neutral close");
|
||||
f.manager.close(next, false);
|
||||
h.assertTrue(
|
||||
f.manager.open(f.player, f.shop, ShopGameplayMode.CHILL).equals("opened"),
|
||||
"Standard to Chill");
|
||||
var chill = f.manager.session(f.shop.id);
|
||||
for (var outcome : List.of(OrderOutcome.SUCCESS, OrderOutcome.REFUSED, OrderOutcome.TIMEOUT)) {
|
||||
chill.order =
|
||||
new ShopOrder(
|
||||
UUID.randomUUID(),
|
||||
UUID.randomUUID(),
|
||||
Map.of("minecraft:oak_planks", 1),
|
||||
0,
|
||||
Long.MAX_VALUE);
|
||||
f.manager.finish(chill, outcome);
|
||||
h.assertTrue(
|
||||
f.manager.reputation(f.shop.owner) == 500, "Chill reputation changed: " + outcome);
|
||||
}
|
||||
f.manager.close(chill, false);
|
||||
h.assertTrue(
|
||||
f.manager.open(f.player, f.shop, ShopGameplayMode.STANDARD).equals("opened"),
|
||||
"Chill back to Standard");
|
||||
h.assertTrue(
|
||||
!f.manager.session(f.shop.id).quantities, "Advanced snapshot threshold not applied");
|
||||
f.finish(h);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void exactAtomicDelivery(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
var stock =
|
||||
new SimpleContainer(
|
||||
stamped(f, Items.STONE, 64),
|
||||
stamped(f, Items.TORCH, 5),
|
||||
stamped(f, Items.TORCH, 3),
|
||||
new ItemStack(Items.DIAMOND));
|
||||
h.assertTrue(
|
||||
!Delivery.consume(stock, f.session, Map.of("minecraft:stone", 24, "minecraft:torch", 9)),
|
||||
"Partial delivery accepted");
|
||||
h.assertTrue(stock.getItem(0).getCount() == 64, "Failed transaction consumed items");
|
||||
h.assertTrue(
|
||||
Delivery.consume(stock, f.session, Map.of("minecraft:stone", 24, "minecraft:torch", 8)),
|
||||
"Valid split stacks rejected");
|
||||
h.assertTrue(
|
||||
stock.getItem(0).getCount() == 40
|
||||
&& stock.getItem(1).isEmpty()
|
||||
&& stock.getItem(2).isEmpty()
|
||||
&& stock.getItem(3).is(Items.DIAMOND),
|
||||
"Surplus mismatch");
|
||||
f.finish(h);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void shiftCraftingAndFullChain(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
var menu =
|
||||
new CraftingMenu(
|
||||
1, f.player.getInventory(), ContainerLevelAccess.create(h.getLevel(), f.crafting));
|
||||
f.player.containerMenu = menu;
|
||||
menu.getSlot(1).setByPlayer(new ItemStack(Items.OAK_LOG, 2));
|
||||
menu.clicked(0, 0, ClickType.QUICK_MOVE, f.player);
|
||||
int valid = 0;
|
||||
for (ItemStack stack : f.player.getInventory().items)
|
||||
if (stack.is(Items.OAK_PLANKS) && ShopProductionTracker.valid(f.session, stack))
|
||||
valid += stack.getCount();
|
||||
h.assertTrue(valid == 8, "Shift crafting should mark exactly 8 planks, got " + valid);
|
||||
for (int i = 1; i <= 9; i++) menu.getSlot(i).setByPlayer(ItemStack.EMPTY);
|
||||
menu.getSlot(1).setByPlayer(new ItemStack(Items.OAK_PLANKS));
|
||||
menu.getSlot(4).setByPlayer(new ItemStack(Items.OAK_PLANKS));
|
||||
menu.clicked(0, 0, ClickType.PICKUP, f.player);
|
||||
h.assertTrue(
|
||||
menu.getCarried().is(Items.STICK)
|
||||
&& !ShopProductionTracker.valid(f.session, menu.getCarried()),
|
||||
"External intermediate laundered");
|
||||
f.finish(h);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void inventoryCrafting(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
var menu = f.player.inventoryMenu;
|
||||
f.player.containerMenu = menu;
|
||||
menu.getSlot(1).setByPlayer(new ItemStack(Items.OAK_LOG));
|
||||
menu.clicked(0, 0, ClickType.PICKUP, f.player);
|
||||
h.assertTrue(
|
||||
menu.getCarried().is(Items.OAK_PLANKS)
|
||||
&& ShopProductionTracker.valid(f.session, menu.getCarried()),
|
||||
"Inventory craft not tracked");
|
||||
f.finish(h);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 500)
|
||||
public static void manualFurnaceOutput(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
var be = (AbstractFurnaceBlockEntity) h.getLevel().getBlockEntity(f.furnace);
|
||||
var menu = new FurnaceMenu(2, f.player.getInventory(), be, new SimpleContainerData(4));
|
||||
f.player.containerMenu = menu;
|
||||
menu.setCarried(new ItemStack(Items.RAW_IRON, 2));
|
||||
menu.clicked(0, 0, ClickType.PICKUP, f.player);
|
||||
menu.setCarried(new ItemStack(Items.COAL));
|
||||
menu.clicked(1, 0, ClickType.PICKUP, f.player);
|
||||
h.runAfterDelay(
|
||||
410,
|
||||
() -> {
|
||||
h.assertTrue(
|
||||
be.getItem(2).getCount() == 2
|
||||
&& ShopProductionTracker.valid(f.session, be.getItem(2)),
|
||||
"Furnace did not track both operations");
|
||||
f.finish(h);
|
||||
});
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 260)
|
||||
public static void automaticFurnaceIsNotAccredited(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
var be = (AbstractFurnaceBlockEntity) h.getLevel().getBlockEntity(f.furnace);
|
||||
be.setItem(0, new ItemStack(Items.RAW_IRON));
|
||||
be.setItem(1, new ItemStack(Items.COAL));
|
||||
h.runAfterDelay(
|
||||
210,
|
||||
() -> {
|
||||
h.assertTrue(
|
||||
be.getItem(2).is(Items.IRON_INGOT)
|
||||
&& !ShopProductionTracker.valid(f.session, be.getItem(2)),
|
||||
"External furnace loading accredited");
|
||||
f.finish(h);
|
||||
});
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void storagePermissionsAndSingleSession(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
ServerPlayer other = h.makeMockServerPlayerInLevel();
|
||||
var storage =
|
||||
(ShopInventoryBlockEntity) h.getLevel().getBlockEntity(f.shop.storages.iterator().next());
|
||||
var menu = new ShopMenu(3, other.getInventory(), storage, f.player.getUUID());
|
||||
h.assertTrue(menu.getSlot(0).mayPlace(new ItemStack(Items.DIAMOND)), "Outsider cannot supply");
|
||||
h.assertTrue(
|
||||
!menu.getSlot(0).mayPickup(other)
|
||||
&& !menu.getSlot(0).mayPlace(new ItemStack(Items.DIAMOND_SWORD)),
|
||||
"Storage permission bypass");
|
||||
h.assertTrue(
|
||||
f.manager.open(f.player, f.shop, ShopGameplayMode.STANDARD).equals("already_open"),
|
||||
"Second session permitted");
|
||||
var second =
|
||||
new Shop(
|
||||
UUID.randomUUID(),
|
||||
f.player.getUUID(),
|
||||
f.shop.dimension,
|
||||
f.shop.counter.offset(40, 0, 0));
|
||||
f.manager.data.shops.put(second.id, second);
|
||||
h.assertTrue(
|
||||
f.manager.open(f.player, second, ShopGameplayMode.CHILL).equals("already_open"),
|
||||
"Another shop opened for the same owner");
|
||||
f.manager.data.shops.remove(second.id);
|
||||
other.server.getPlayerList().remove(other);
|
||||
f.finish(h);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 180)
|
||||
public static void easyNpcLifecycle(GameTestHelper h) {
|
||||
npcLifecycle(h, "easy_npc:humanoid");
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 180)
|
||||
public static void villagerNpcLifecycle(GameTestHelper h) {
|
||||
npcLifecycle(h, "easy_npc:villager");
|
||||
}
|
||||
|
||||
private static void npcLifecycle(GameTestHelper h, String type) {
|
||||
Fixture f = setup(h);
|
||||
var adapter = new EasyNpcAdapter(List.of(type));
|
||||
var npc =
|
||||
adapter
|
||||
.spawn(h.getLevel(), f.session, f.shop.entry)
|
||||
.orElseThrow(() -> new AssertionError("Easy NPC spawn failed"));
|
||||
adapter.say(npc, "cos.npc.order");
|
||||
adapter.move(npc, h.absolutePos(new BlockPos(4, 1, 5)));
|
||||
h.runAfterDelay(
|
||||
120,
|
||||
() -> {
|
||||
h.assertTrue(
|
||||
npc.distanceToSqr(
|
||||
f.shop.entry.getX() + .5,
|
||||
f.shop.entry.getY() + .125,
|
||||
f.shop.entry.getZ() + .5)
|
||||
> 1,
|
||||
"Easy NPC did not navigate");
|
||||
adapter.remove(npc);
|
||||
h.assertTrue(npc.isRemoved(), "NPC was not removed");
|
||||
f.finish(h);
|
||||
});
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void stonecutterAndSmithing(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
f.manager.close(f.session, false);
|
||||
h.setBlock(8, 1, 3, Blocks.STONECUTTER);
|
||||
h.setBlock(6, 1, 3, Blocks.SMITHING_TABLE);
|
||||
h.assertTrue(
|
||||
f.manager.open(f.player, f.shop, ShopGameplayMode.CHILL).equals("opened"),
|
||||
"Reopen with new stations");
|
||||
var session = f.manager.session(f.shop.id);
|
||||
session.nextArrival = Long.MAX_VALUE;
|
||||
var stone =
|
||||
new StonecutterMenu(
|
||||
4, f.player.getInventory(), ContainerLevelAccess.create(h.getLevel(), f.furnace));
|
||||
f.player.containerMenu = stone;
|
||||
stone.getSlot(0).setByPlayer(new ItemStack(Items.COBBLESTONE));
|
||||
h.assertTrue(stone.clickMenuButton(f.player, 0), "Stone recipe missing");
|
||||
stone.clicked(1, 0, ClickType.PICKUP, f.player);
|
||||
h.assertTrue(
|
||||
ShopProductionTracker.valid(session, stone.getCarried()), "Stonecutting not tracked");
|
||||
var smith =
|
||||
new SmithingMenu(
|
||||
5, f.player.getInventory(), ContainerLevelAccess.create(h.getLevel(), f.crafting));
|
||||
f.player.containerMenu = smith;
|
||||
ItemStack sword = new ItemStack(Items.DIAMOND_SWORD);
|
||||
sword.set(
|
||||
CounterOfShinuwa.PRODUCTION.get(),
|
||||
new ShopProductionData(
|
||||
f.shop.id,
|
||||
session.id,
|
||||
f.player.getUUID(),
|
||||
"counter_of_shinuwa:manual",
|
||||
"counter_of_shinuwa:crafting"));
|
||||
smith.getSlot(0).setByPlayer(new ItemStack(Items.NETHERITE_UPGRADE_SMITHING_TEMPLATE));
|
||||
smith.getSlot(1).setByPlayer(sword);
|
||||
smith.getSlot(2).setByPlayer(new ItemStack(Items.NETHERITE_INGOT));
|
||||
smith.clicked(3, 0, ClickType.PICKUP, f.player);
|
||||
h.assertTrue(
|
||||
smith.getCarried().is(Items.NETHERITE_SWORD)
|
||||
&& ShopProductionTracker.valid(session, smith.getCarried()),
|
||||
"Smithing not tracked");
|
||||
f.finish(h);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 800)
|
||||
public static void completeSaleAndGracefulClose(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
f.manager.data.reputation(f.shop.owner, 500);
|
||||
f.session.nextArrival = h.getLevel().getGameTime();
|
||||
h.runAfterDelay(
|
||||
280,
|
||||
() -> {
|
||||
h.assertTrue(
|
||||
f.session.order != null,
|
||||
"Customer never reached counter; session="
|
||||
+ f.manager.session(f.shop.id)
|
||||
+ " state="
|
||||
+ f.session.state
|
||||
+ " customers="
|
||||
+ f.session.customers.stream()
|
||||
.map(
|
||||
c ->
|
||||
c.state
|
||||
+ " wp="
|
||||
+ c.waypoint
|
||||
+ " target="
|
||||
+ c.target
|
||||
+ " npc="
|
||||
+ h.getLevel().getEntity(c.npc))
|
||||
.toList());
|
||||
var original = f.session.order;
|
||||
f.session.order =
|
||||
new ShopOrder(
|
||||
UUID.randomUUID(),
|
||||
original.customer(),
|
||||
Map.of("minecraft:oak_planks", 4),
|
||||
6,
|
||||
h.getLevel().getGameTime() + 1000);
|
||||
var craft =
|
||||
new CraftingMenu(
|
||||
6,
|
||||
f.player.getInventory(),
|
||||
ContainerLevelAccess.create(h.getLevel(), f.crafting));
|
||||
f.player.containerMenu = craft;
|
||||
craft.getSlot(1).setByPlayer(new ItemStack(Items.OAK_LOG));
|
||||
craft.clicked(0, 0, ClickType.PICKUP, f.player);
|
||||
var be = (ShopInventoryBlockEntity) h.getLevel().getBlockEntity(f.shop.counter);
|
||||
be.setItem(0, craft.getCarried());
|
||||
craft.setCarried(ItemStack.EMPTY);
|
||||
var counter = new ShopMenu(7, f.player.getInventory(), be, f.player.getUUID());
|
||||
f.player.containerMenu = counter;
|
||||
f.manager.action(f.player, counter, 8);
|
||||
f.manager.action(f.player, counter, 8);
|
||||
h.assertTrue(
|
||||
f.player.getInventory().countItem(Items.EMERALD) == 6,
|
||||
"Payment duplicated or missing");
|
||||
h.assertTrue(f.manager.reputation(f.shop.owner) == 500, "Chill changed reputation");
|
||||
f.manager.action(f.player, counter, 5);
|
||||
});
|
||||
h.runAfterDelay(
|
||||
720,
|
||||
() -> {
|
||||
h.assertTrue(
|
||||
f.manager.session(f.shop.id) == null, "Graceful close did not finish departure");
|
||||
f.finish(h);
|
||||
});
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 260)
|
||||
public static void oldFurnaceOutputCannotBeLaundered(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
var be = (AbstractFurnaceBlockEntity) h.getLevel().getBlockEntity(f.furnace);
|
||||
be.setItem(2, new ItemStack(Items.IRON_INGOT));
|
||||
var menu = new FurnaceMenu(8, f.player.getInventory(), be, new SimpleContainerData(4));
|
||||
f.player.containerMenu = menu;
|
||||
menu.setCarried(new ItemStack(Items.RAW_IRON));
|
||||
menu.clicked(0, 0, ClickType.PICKUP, f.player);
|
||||
menu.setCarried(new ItemStack(Items.COAL));
|
||||
menu.clicked(1, 0, ClickType.PICKUP, f.player);
|
||||
h.runAfterDelay(
|
||||
210,
|
||||
() -> {
|
||||
h.assertTrue(
|
||||
!ShopProductionTracker.valid(f.session, be.getItem(2)), "Old output laundered");
|
||||
f.finish(h);
|
||||
});
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 260)
|
||||
public static void hopperInvalidatesManualBatch(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
var be = (AbstractFurnaceBlockEntity) h.getLevel().getBlockEntity(f.furnace);
|
||||
var menu = new FurnaceMenu(9, f.player.getInventory(), be, new SimpleContainerData(4));
|
||||
f.player.containerMenu = menu;
|
||||
menu.setCarried(new ItemStack(Items.RAW_IRON));
|
||||
menu.clicked(0, 0, ClickType.PICKUP, f.player);
|
||||
menu.setCarried(new ItemStack(Items.COAL));
|
||||
menu.clicked(1, 0, ClickType.PICKUP, f.player);
|
||||
net.minecraft.world.level.block.entity.HopperBlockEntity.addItem(
|
||||
null, be, new ItemStack(Items.RAW_IRON), net.minecraft.core.Direction.UP);
|
||||
h.runAfterDelay(
|
||||
210,
|
||||
() -> {
|
||||
h.assertTrue(
|
||||
!ShopProductionTracker.valid(f.session, be.getItem(2)),
|
||||
"Automated mixed batch laundered");
|
||||
f.finish(h);
|
||||
});
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 80)
|
||||
public static void structuralBreakClosesWithoutPenalty(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
f.manager.data.reputation(f.shop.owner, 500);
|
||||
h.setBlock(4, 1, 5, Blocks.AIR);
|
||||
f.manager.invalidate(h.getLevel(), h.absolutePos(new BlockPos(4, 1, 5)));
|
||||
h.runAfterDelay(
|
||||
25,
|
||||
() -> {
|
||||
h.assertTrue(
|
||||
f.manager.session(f.shop.id) == null && f.manager.reputation(f.shop.owner) == 500,
|
||||
"Broken path was not closed neutrally");
|
||||
f.finish(h);
|
||||
});
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void fullInventoryRetainsPayment(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
for (int i = 0; i < 36; i++) f.player.getInventory().setItem(i, new ItemStack(Items.STONE, 64));
|
||||
f.shop.credit = 100;
|
||||
f.manager.withdraw(f.shop);
|
||||
h.assertTrue(f.shop.credit == 100, "Full inventory lost credit");
|
||||
f.player.getInventory().setItem(0, ItemStack.EMPTY);
|
||||
f.manager.withdraw(f.shop);
|
||||
h.assertTrue(
|
||||
f.shop.credit == 36 && f.player.getInventory().getItem(0).getCount() == 64,
|
||||
"Partial payout incorrect");
|
||||
f.finish(h);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void addonWithoutCoreChanges(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
f.manager.close(f.session, false);
|
||||
h.setBlock(10, 1, 3, Blocks.BELL);
|
||||
h.assertTrue(
|
||||
f.manager.open(f.player, f.shop, ShopGameplayMode.CHILL).equals("opened"),
|
||||
"Addon station rejected");
|
||||
var s = f.manager.session(f.shop.id);
|
||||
var input = new SimpleContainer(new ItemStack(Items.DIAMOND, 2));
|
||||
h.assertTrue(
|
||||
s.structure.families().contains(TestProductionIntegration.ID), "Addon family missing");
|
||||
h.assertTrue(
|
||||
s.resolver
|
||||
.plan(Map.of("minecraft:amethyst_block", 1), Map.of("minecraft:diamond", 1))
|
||||
.isPresent(),
|
||||
"Addon recipe missing");
|
||||
var result =
|
||||
TestProductionIntegration.produce(
|
||||
s, f.player, h.absolutePos(new BlockPos(10, 1, 3)), input);
|
||||
h.assertTrue(
|
||||
ShopProductionTracker.valid(s, result) && input.getItem(0).getCount() == 1,
|
||||
"Addon production not accredited exactly once");
|
||||
f.finish(h);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 160)
|
||||
public static void blastFurnaceAndSmoker(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
f.manager.close(f.session, false);
|
||||
h.setBlock(8, 1, 3, Blocks.BLAST_FURNACE);
|
||||
h.setBlock(10, 1, 3, Blocks.SMOKER);
|
||||
h.assertTrue(
|
||||
f.manager.open(f.player, f.shop, ShopGameplayMode.CHILL).equals("opened"),
|
||||
"Three families should be accepted");
|
||||
var s = f.manager.session(f.shop.id);
|
||||
s.nextArrival = Long.MAX_VALUE;
|
||||
var blast = (AbstractFurnaceBlockEntity) h.getLevel().getBlockEntity(f.furnace);
|
||||
var smoker = (AbstractFurnaceBlockEntity) h.getBlockEntity(new BlockPos(10, 1, 3));
|
||||
for (var be : List.of(blast, smoker)) {
|
||||
AbstractFurnaceMenu menu =
|
||||
be == blast
|
||||
? new BlastFurnaceMenu(10, f.player.getInventory(), be, new SimpleContainerData(4))
|
||||
: new SmokerMenu(11, f.player.getInventory(), be, new SimpleContainerData(4));
|
||||
f.player.containerMenu = menu;
|
||||
menu.setCarried(new ItemStack(be == blast ? Items.RAW_IRON : Items.BEEF));
|
||||
menu.clicked(0, 0, ClickType.PICKUP, f.player);
|
||||
menu.setCarried(new ItemStack(Items.COAL));
|
||||
menu.clicked(1, 0, ClickType.PICKUP, f.player);
|
||||
}
|
||||
h.runAfterDelay(
|
||||
115,
|
||||
() -> {
|
||||
h.assertTrue(
|
||||
blast.getItem(2).is(Items.IRON_INGOT)
|
||||
&& ShopProductionTracker.valid(s, blast.getItem(2)),
|
||||
"Blast furnace provenance");
|
||||
h.assertTrue(
|
||||
smoker.getItem(2).is(Items.COOKED_BEEF)
|
||||
&& ShopProductionTracker.valid(s, smoker.getItem(2)),
|
||||
"Smoker provenance");
|
||||
f.finish(h);
|
||||
});
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void stockTransfersDoNotDuplicate(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
var initial = ShopStock.collect(h.getLevel(), f.shop, f.session, f.session.structure);
|
||||
var storage =
|
||||
(ShopInventoryBlockEntity) h.getLevel().getBlockEntity(f.shop.storages.iterator().next());
|
||||
var craft =
|
||||
new CraftingMenu(
|
||||
12, f.player.getInventory(), ContainerLevelAccess.create(h.getLevel(), f.crafting));
|
||||
f.player.containerMenu = craft;
|
||||
craft.getSlot(1).setByPlayer(storage.removeItem(0, 2));
|
||||
h.assertTrue(
|
||||
ShopStock.collect(h.getLevel(), f.shop, f.session, f.session.structure).equals(initial),
|
||||
"Menu input counted twice or lost");
|
||||
craft.clicked(0, 0, ClickType.PICKUP, f.player);
|
||||
h.assertTrue(
|
||||
ShopStock.finished(h.getLevel(), f.shop, f.session, f.session.structure)
|
||||
.getOrDefault("minecraft:oak_planks", 0)
|
||||
== 4,
|
||||
"Cursor output not counted exactly once");
|
||||
f.finish(h);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void rejectOtherProducerAndOutsideCraft(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
ItemStack invalid = stamped(f, Items.OAK_PLANKS, 4);
|
||||
invalid.set(
|
||||
CounterOfShinuwa.PRODUCTION.get(),
|
||||
new ShopProductionData(f.shop.id, f.session.id, UUID.randomUUID(), "manual", "crafting"));
|
||||
h.assertTrue(
|
||||
!Delivery.consume(
|
||||
new SimpleContainer(invalid), f.session, Map.of("minecraft:oak_planks", 4)),
|
||||
"Other producer accepted");
|
||||
BlockPos outside = h.absolutePos(new BlockPos(0, 1, 0));
|
||||
f.player.setPos(outside.getX(), outside.getY(), outside.getZ());
|
||||
f.player.containerMenu = f.player.inventoryMenu;
|
||||
var menu = f.player.inventoryMenu;
|
||||
menu.getSlot(1).setByPlayer(new ItemStack(Items.OAK_LOG));
|
||||
menu.clicked(0, 0, ClickType.PICKUP, f.player);
|
||||
h.assertTrue(
|
||||
!ShopProductionTracker.valid(f.session, menu.getCarried()), "Outside craft accredited");
|
||||
f.finish(h);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void craftingRemaindersKeepProvenance(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
var menu =
|
||||
new CraftingMenu(
|
||||
13, f.player.getInventory(), ContainerLevelAccess.create(h.getLevel(), f.crafting));
|
||||
f.player.containerMenu = menu;
|
||||
for (int i = 1; i <= 3; i++) menu.getSlot(i).setByPlayer(stamped(f, Items.MILK_BUCKET, 1));
|
||||
menu.getSlot(4).setByPlayer(stamped(f, Items.SUGAR, 1));
|
||||
menu.getSlot(5).setByPlayer(new ItemStack(Items.EGG));
|
||||
menu.getSlot(6).setByPlayer(stamped(f, Items.SUGAR, 1));
|
||||
for (int i = 7; i <= 9; i++) menu.getSlot(i).setByPlayer(new ItemStack(Items.WHEAT));
|
||||
menu.clicked(0, 0, ClickType.PICKUP, f.player);
|
||||
h.assertTrue(
|
||||
menu.getCarried().is(Items.CAKE)
|
||||
&& ShopProductionTracker.valid(f.session, menu.getCarried()),
|
||||
"Cake production failed");
|
||||
for (int i = 1; i <= 3; i++)
|
||||
h.assertTrue(
|
||||
menu.getSlot(i).getItem().is(Items.BUCKET)
|
||||
&& ShopProductionTracker.valid(f.session, menu.getSlot(i).getItem()),
|
||||
"Remainder lost provenance");
|
||||
f.finish(h);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 80)
|
||||
public static void hoppersCannotWithdrawShopStock(GameTestHelper h) {
|
||||
Fixture f = setup(h);
|
||||
var pos = f.shop.storages.iterator().next();
|
||||
h.getLevel().setBlockAndUpdate(pos.below(), Blocks.HOPPER.defaultBlockState());
|
||||
h.runAfterDelay(
|
||||
25,
|
||||
() -> {
|
||||
var storage = (ShopInventoryBlockEntity) h.getLevel().getBlockEntity(pos);
|
||||
var hopper =
|
||||
(net.minecraft.world.level.block.entity.HopperBlockEntity)
|
||||
h.getLevel().getBlockEntity(pos.below());
|
||||
h.assertTrue(
|
||||
storage.getItem(0).getCount() == 32 && hopper.isEmpty(),
|
||||
"Hopper bypassed owner withdrawal permission");
|
||||
f.finish(h);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package fr.shinuwa.counterofshinuwa.test;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.core.ShopSession;
|
||||
import fr.shinuwa.counterofshinuwa.production.*;
|
||||
import fr.shinuwa.counterofshinuwa.recipe.ProductionRecipe;
|
||||
import fr.shinuwa.counterofshinuwa.station.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
|
||||
/** Test-only addon: a bell transforms a diamond into an amethyst block. No core branches. */
|
||||
public final class TestProductionIntegration implements ProductionHandler {
|
||||
public static final String ID = "counter_of_shinuwa_tests:polishing";
|
||||
|
||||
public static void register() {
|
||||
ProductionRegistry.INSTANCE.register(
|
||||
state ->
|
||||
state.is(Blocks.BELL)
|
||||
? Optional.of(new StationDefinition(ID, ID, ID))
|
||||
: Optional.empty(),
|
||||
level ->
|
||||
List.of(
|
||||
new ProductionRecipe(
|
||||
ID,
|
||||
List.of(Set.of("minecraft:diamond")),
|
||||
"minecraft:amethyst_block",
|
||||
1,
|
||||
Map.of(),
|
||||
ID,
|
||||
ID,
|
||||
20,
|
||||
false)),
|
||||
() -> Set.of(ID),
|
||||
new TestProductionIntegration());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean accepts(ShopSession session, List<ItemStack> inputs) {
|
||||
return inputs.size() == 1
|
||||
&& inputs.getFirst().is(Items.DIAMOND)
|
||||
&& ShopProductionTracker.ingredient(session, inputs.getFirst());
|
||||
}
|
||||
|
||||
public static ItemStack produce(
|
||||
ShopSession session, ServerPlayer player, BlockPos pos, Container input) {
|
||||
if (!player.level().getBlockState(pos).is(Blocks.BELL)
|
||||
|| !session.shop.contains(player.blockPosition())) return ItemStack.EMPTY;
|
||||
ItemStack output = new ItemStack(Items.AMETHYST_BLOCK);
|
||||
if (!ShopProductionTracker.produced(
|
||||
new ProductionContext(session, player.getUUID(), pos, ID, ID),
|
||||
List.of(input.getItem(0).copy()),
|
||||
output)) return ItemStack.EMPTY;
|
||||
input.removeItem(0, 1);
|
||||
return output;
|
||||
}
|
||||
}
|
||||
7
src/gameTest/resources/META-INF/neoforge.mods.toml
Normal file
7
src/gameTest/resources/META-INF/neoforge.mods.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
modLoader="javafml"
|
||||
loaderVersion="[4,)"
|
||||
license="All Rights Reserved"
|
||||
[[mods]]
|
||||
modId="counter_of_shinuwa_tests"
|
||||
version="1.0.0"
|
||||
displayName="Counter of Shinuwa GameTests"
|
||||
Binary file not shown.
134
src/main/java/fr/shinuwa/counterofshinuwa/CounterOfShinuwa.java
Normal file
134
src/main/java/fr/shinuwa/counterofshinuwa/CounterOfShinuwa.java
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package fr.shinuwa.counterofshinuwa;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import fr.shinuwa.counterofshinuwa.block.*;
|
||||
import fr.shinuwa.counterofshinuwa.compat.vanilla.VanillaIntegration;
|
||||
import fr.shinuwa.counterofshinuwa.core.ShopEvents;
|
||||
import fr.shinuwa.counterofshinuwa.menu.ShopMenu;
|
||||
import fr.shinuwa.counterofshinuwa.network.ShopNetwork;
|
||||
import fr.shinuwa.counterofshinuwa.production.ShopProductionData;
|
||||
import net.minecraft.core.component.DataComponentType;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.inventory.MenuType;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.entity.BlockEntityType;
|
||||
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||
import net.neoforged.bus.api.IEventBus;
|
||||
import net.neoforged.fml.ModContainer;
|
||||
import net.neoforged.fml.common.Mod;
|
||||
import net.neoforged.fml.config.ModConfig;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.common.extensions.IMenuTypeExtension;
|
||||
import net.neoforged.neoforge.registries.*;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
@Mod(CounterOfShinuwa.MODID)
|
||||
public final class CounterOfShinuwa {
|
||||
public static final String MODID = "counter_of_shinuwa";
|
||||
public static final Logger LOGGER = LogUtils.getLogger();
|
||||
public static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MODID);
|
||||
public static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MODID);
|
||||
public static final DeferredRegister<BlockEntityType<?>> ENTITIES =
|
||||
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, MODID);
|
||||
public static final DeferredRegister<MenuType<?>> MENUS =
|
||||
DeferredRegister.create(Registries.MENU, MODID);
|
||||
public static final DeferredRegister<DataComponentType<?>> COMPONENTS =
|
||||
DeferredRegister.create(Registries.DATA_COMPONENT_TYPE, MODID);
|
||||
public static final DeferredRegister<CreativeModeTab> TABS =
|
||||
DeferredRegister.create(Registries.CREATIVE_MODE_TAB, MODID);
|
||||
public static final DeferredBlock<ShopInventoryBlock> COUNTER =
|
||||
BLOCKS.register("counter", () -> new ShopInventoryBlock(false));
|
||||
public static final DeferredBlock<ShopInventoryBlock> STORAGE =
|
||||
BLOCKS.register("material_storage", () -> new ShopInventoryBlock(true));
|
||||
public static final DeferredBlock<Block> MARKER =
|
||||
BLOCKS.registerSimpleBlock("zone_marker", BlockBehaviour.Properties.of().strength(2));
|
||||
public static final DeferredBlock<Block> ACCESS =
|
||||
BLOCKS.register("access_point", () -> new AccessPointBlock());
|
||||
public static final DeferredItem<BlockItem> COUNTER_ITEM = ITEMS.registerSimpleBlockItem(COUNTER);
|
||||
public static final DeferredItem<BlockItem> STORAGE_ITEM = ITEMS.registerSimpleBlockItem(STORAGE);
|
||||
public static final DeferredItem<BlockItem> MARKER_ITEM = ITEMS.registerSimpleBlockItem(MARKER);
|
||||
public static final DeferredItem<BlockItem> ACCESS_ITEM = ITEMS.registerSimpleBlockItem(ACCESS);
|
||||
public static final DeferredItem<LinkingTool> LINKER =
|
||||
ITEMS.register("linking_tool", () -> new LinkingTool(new Item.Properties().stacksTo(1)));
|
||||
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<ShopInventoryBlockEntity>>
|
||||
INVENTORY =
|
||||
ENTITIES.register(
|
||||
"shop_inventory",
|
||||
() ->
|
||||
BlockEntityType.Builder.of(
|
||||
ShopInventoryBlockEntity::new, COUNTER.get(), STORAGE.get())
|
||||
.build(null));
|
||||
public static final DeferredHolder<MenuType<?>, MenuType<ShopMenu>> SHOP_MENU =
|
||||
MENUS.register(
|
||||
"shop",
|
||||
() ->
|
||||
IMenuTypeExtension.create(
|
||||
(id, inv, buf) ->
|
||||
new ShopMenu(
|
||||
id, inv, buf.readBlockPos(), buf.readBoolean(), buf.readUUID())));
|
||||
public static final DeferredHolder<DataComponentType<?>, DataComponentType<ShopProductionData>>
|
||||
PRODUCTION =
|
||||
COMPONENTS.register(
|
||||
"production",
|
||||
() ->
|
||||
DataComponentType.<ShopProductionData>builder()
|
||||
.persistent(ShopProductionData.CODEC)
|
||||
.networkSynchronized(
|
||||
ByteBufCodecs.fromCodecWithRegistries(ShopProductionData.CODEC))
|
||||
.build());
|
||||
public static final DeferredHolder<CreativeModeTab, CreativeModeTab> TAB =
|
||||
TABS.register(
|
||||
"shop",
|
||||
() ->
|
||||
CreativeModeTab.builder()
|
||||
.title(
|
||||
net.minecraft.network.chat.Component.translatable(
|
||||
"itemGroup.counter_of_shinuwa"))
|
||||
.icon(() -> new ItemStack(COUNTER_ITEM.get()))
|
||||
.displayItems(
|
||||
(p, out) -> {
|
||||
out.accept(COUNTER_ITEM.get());
|
||||
out.accept(STORAGE_ITEM.get());
|
||||
out.accept(MARKER_ITEM.get());
|
||||
out.accept(ACCESS_ITEM.get());
|
||||
out.accept(LINKER.get());
|
||||
})
|
||||
.build());
|
||||
|
||||
public CounterOfShinuwa(IEventBus bus, ModContainer container) {
|
||||
BLOCKS.register(bus);
|
||||
ITEMS.register(bus);
|
||||
ENTITIES.register(bus);
|
||||
MENUS.register(bus);
|
||||
COMPONENTS.register(bus);
|
||||
TABS.register(bus);
|
||||
container.registerConfig(ModConfig.Type.SERVER, ShopConfig.SPEC);
|
||||
VanillaIntegration.register();
|
||||
bus.addListener(ShopNetwork::register);
|
||||
bus.addListener(
|
||||
(net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent event) ->
|
||||
event.enqueueWork(
|
||||
fr.shinuwa.counterofshinuwa.integration.easynpc.EasyNpcAdapter::register));
|
||||
NeoForge.EVENT_BUS.register(ShopEvents.class);
|
||||
NeoForge.EVENT_BUS.addListener(fr.shinuwa.counterofshinuwa.core.ShopCommands::register);
|
||||
bus.addListener(
|
||||
(net.neoforged.fml.event.config.ModConfigEvent.Reloading event) -> {
|
||||
if (event.getConfig().getSpec() != ShopConfig.SPEC) return;
|
||||
var server = net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer();
|
||||
if (server != null)
|
||||
server.execute(
|
||||
() -> {
|
||||
fr.shinuwa.counterofshinuwa.core.ShopManager.get(server).closeAll();
|
||||
fr.shinuwa.counterofshinuwa.station.ProductionRegistry.INSTANCE.resetProduction();
|
||||
fr.shinuwa.counterofshinuwa.station.ProductionRegistry.INSTANCE.invalidate();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static ResourceLocation id(String path) {
|
||||
return ResourceLocation.fromNamespaceAndPath(MODID, path);
|
||||
}
|
||||
}
|
||||
185
src/main/java/fr/shinuwa/counterofshinuwa/ShopConfig.java
Normal file
185
src/main/java/fr/shinuwa/counterofshinuwa/ShopConfig.java
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package fr.shinuwa.counterofshinuwa;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.gameplay.ShopGameplayRules.RuleSettings;
|
||||
import java.util.List;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.tags.TagKey;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.common.ModConfigSpec;
|
||||
|
||||
public final class ShopConfig {
|
||||
private static final ModConfigSpec.Builder B = new ModConfigSpec.Builder();
|
||||
public static final ModConfigSpec.IntValue MAX_WIDTH =
|
||||
B.defineInRange("max_horizontal_size", 32, 3, 128);
|
||||
public static final ModConfigSpec.IntValue MAX_HEIGHT = B.defineInRange("max_height", 16, 3, 64);
|
||||
public static final ModConfigSpec.BooleanValue LIMIT_FAMILIES =
|
||||
B.define("station_type_limit_enabled", true);
|
||||
public static final ModConfigSpec.IntValue MAX_FAMILIES =
|
||||
B.defineInRange("max_station_types_per_shop", 3, 1, 64);
|
||||
public static final ModConfigSpec.IntValue MAX_REPUTATION =
|
||||
B.defineInRange("max_reputation", 1000, 1, 100000);
|
||||
public static final ModConfigSpec.IntValue ADVANCED =
|
||||
B.defineInRange("advanced_stock_threshold", 450, 0, 100000);
|
||||
public static final ModConfigSpec.IntValue SUCCESS =
|
||||
B.defineInRange("standard_success", 2, 0, 1000);
|
||||
public static final ModConfigSpec.IntValue REFUSED =
|
||||
B.defineInRange("standard_refused", -2, -1000, 0);
|
||||
public static final ModConfigSpec.IntValue TIMEOUT =
|
||||
B.defineInRange("standard_timeout", -5, -1000, 0);
|
||||
public static final ModConfigSpec.IntValue FORCE =
|
||||
B.defineInRange("standard_force_close", -10, -1000, 0);
|
||||
public static final ModConfigSpec.DoubleValue CHILL_REWARD =
|
||||
B.defineInRange("chill_reward_multiplier", .6, 0, 1);
|
||||
public static final ModConfigSpec.IntValue STANDARD_INTERVAL =
|
||||
B.defineInRange("standard_spawn_ticks", 900, 100, 72000);
|
||||
public static final ModConfigSpec.IntValue CHILL_INTERVAL =
|
||||
B.defineInRange("chill_spawn_ticks", 1800, 100, 72000);
|
||||
public static final ModConfigSpec.IntValue STANDARD_QUEUE =
|
||||
B.defineInRange("standard_queue_limit", 6, 1, 20);
|
||||
public static final ModConfigSpec.IntValue CHILL_QUEUE =
|
||||
B.defineInRange("chill_queue_limit", 3, 1, 20);
|
||||
public static final ModConfigSpec.IntValue QUANTITY =
|
||||
B.defineInRange("max_order_quantity", 16, 1, 64);
|
||||
public static final ModConfigSpec.DoubleValue CHILL_PATIENCE =
|
||||
B.defineInRange("chill_patience_multiplier", 2.0, 1, 10);
|
||||
public static final ModConfigSpec.IntValue BASE_PRICE =
|
||||
B.defineInRange("default_unit_price", 2, 1, 10000);
|
||||
public static final ModConfigSpec.DoubleValue COMPLEXITY =
|
||||
B.defineInRange("complexity_price_per_step", .5, 0, 100);
|
||||
public static final ModConfigSpec.BooleanValue INSPECTION =
|
||||
B.define("inspection_mode_enabled", true);
|
||||
public static final ModConfigSpec.ConfigValue<List<? extends String>> MATERIALS =
|
||||
B.defineListAllowEmpty(
|
||||
"material_whitelist",
|
||||
List.of(
|
||||
"#minecraft:logs",
|
||||
"minecraft:coal",
|
||||
"minecraft:charcoal",
|
||||
"minecraft:raw_iron",
|
||||
"minecraft:raw_gold",
|
||||
"minecraft:raw_copper",
|
||||
"minecraft:iron_ingot",
|
||||
"minecraft:gold_ingot",
|
||||
"minecraft:copper_ingot",
|
||||
"minecraft:diamond",
|
||||
"minecraft:redstone",
|
||||
"minecraft:lapis_lazuli",
|
||||
"minecraft:quartz",
|
||||
"minecraft:cobblestone",
|
||||
"minecraft:cobbled_deepslate",
|
||||
"minecraft:sand",
|
||||
"minecraft:clay_ball",
|
||||
"minecraft:netherite_ingot",
|
||||
"minecraft:netherite_upgrade_smithing_template",
|
||||
"minecraft:wheat",
|
||||
"minecraft:carrot",
|
||||
"minecraft:potato",
|
||||
"minecraft:beef",
|
||||
"minecraft:porkchop",
|
||||
"minecraft:chicken",
|
||||
"minecraft:mutton",
|
||||
"minecraft:rabbit",
|
||||
"minecraft:cod",
|
||||
"minecraft:salmon",
|
||||
"minecraft:egg",
|
||||
"minecraft:sugar_cane",
|
||||
"minecraft:leather",
|
||||
"minecraft:string",
|
||||
"#minecraft:wool"),
|
||||
() -> "minecraft:stone",
|
||||
o -> o instanceof String);
|
||||
public static final ModConfigSpec.ConfigValue<List<? extends String>> BLACKLIST =
|
||||
B.defineListAllowEmpty(
|
||||
"product_blacklist",
|
||||
List.of("minecraft:emerald", "minecraft:emerald_block"),
|
||||
() -> "minecraft:stone",
|
||||
o -> o instanceof String);
|
||||
public static final ModConfigSpec.ConfigValue<List<? extends String>> DISABLED_FAMILIES =
|
||||
B.defineListAllowEmpty(
|
||||
"disabled_station_families",
|
||||
List.<String>of(),
|
||||
() -> "counter_of_shinuwa:forge",
|
||||
o -> o instanceof String);
|
||||
public static final ModConfigSpec.ConfigValue<List<? extends String>> DISABLED_HANDLERS =
|
||||
B.defineListAllowEmpty(
|
||||
"disabled_production_handlers",
|
||||
List.<String>of(),
|
||||
() -> "counter_of_shinuwa:furnace",
|
||||
o -> o instanceof String);
|
||||
public static final ModConfigSpec.ConfigValue<List<? extends String>> PRICES =
|
||||
B.defineListAllowEmpty(
|
||||
"product_prices",
|
||||
List.of(
|
||||
"minecraft:diamond_sword=24",
|
||||
"minecraft:iron_sword=6",
|
||||
"minecraft:netherite_sword=48"),
|
||||
() -> "minecraft:bread=2",
|
||||
o -> o instanceof String);
|
||||
public static final ModConfigSpec.ConfigValue<List<? extends String>> APPEARANCES =
|
||||
B.defineListAllowEmpty(
|
||||
"customer_entity_types",
|
||||
List.of("easy_npc:humanoid", "easy_npc:villager"),
|
||||
() -> "easy_npc:humanoid",
|
||||
o -> o instanceof String);
|
||||
public static final ModConfigSpec.ConfigValue<List<? extends Integer>> RANKS =
|
||||
B.defineList(
|
||||
"rank_thresholds",
|
||||
List.of(0, 100, 250, 450, 700, 900),
|
||||
o -> o instanceof Integer i && i >= 0);
|
||||
public static final ModConfigSpec SPEC = B.build();
|
||||
|
||||
public static int rank(int reputation) {
|
||||
int rank = 0;
|
||||
for (int threshold : RANKS.get()) if (reputation >= threshold) rank++;
|
||||
return Math.max(0, Math.min(5, rank - 1));
|
||||
}
|
||||
|
||||
private ShopConfig() {}
|
||||
|
||||
public static RuleSettings rules() {
|
||||
return new RuleSettings(
|
||||
ADVANCED.get(),
|
||||
CHILL_REWARD.get(),
|
||||
SUCCESS.get(),
|
||||
REFUSED.get(),
|
||||
TIMEOUT.get(),
|
||||
FORCE.get(),
|
||||
STANDARD_INTERVAL.get(),
|
||||
CHILL_INTERVAL.get(),
|
||||
STANDARD_QUEUE.get(),
|
||||
CHILL_QUEUE.get(),
|
||||
QUANTITY.get(),
|
||||
CHILL_PATIENCE.get());
|
||||
}
|
||||
|
||||
public static boolean matches(ItemStack stack, List<? extends String> selectors) {
|
||||
if (stack.isEmpty()) return false;
|
||||
for (String selector : selectors) {
|
||||
ResourceLocation id =
|
||||
ResourceLocation.tryParse(selector.startsWith("#") ? selector.substring(1) : selector);
|
||||
if (id == null) continue;
|
||||
if (selector.startsWith("#")
|
||||
? stack.is(TagKey.create(Registries.ITEM, id))
|
||||
: BuiltInRegistries.ITEM.getKey(stack.getItem()).equals(id)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean material(ItemStack stack) {
|
||||
return matches(stack, MATERIALS.get());
|
||||
}
|
||||
|
||||
public static int price(String item) {
|
||||
for (String line : PRICES.get()) {
|
||||
String[] pair = line.split("=", 2);
|
||||
if (pair.length == 2 && pair[0].equals(item))
|
||||
try {
|
||||
return Math.max(1, Math.min(10000, Integer.parseInt(pair[1])));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
return BASE_PRICE.get();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package fr.shinuwa.counterofshinuwa.block;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.shapes.*;
|
||||
|
||||
public final class AccessPointBlock extends Block {
|
||||
public AccessPointBlock() {
|
||||
super(Properties.of().strength(2).noOcclusion());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected VoxelShape getShape(
|
||||
BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
|
||||
return Block.box(0, 0, 0, 16, 1, 16);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package fr.shinuwa.counterofshinuwa.block;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.core.*;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.component.CustomData;
|
||||
import net.minecraft.world.item.context.UseOnContext;
|
||||
|
||||
public final class LinkingTool extends Item {
|
||||
public LinkingTool(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InteractionResult useOn(UseOnContext context) {
|
||||
if (!(context.getPlayer() instanceof ServerPlayer player)) return InteractionResult.SUCCESS;
|
||||
var manager = ShopManager.get(player.server);
|
||||
var pos = context.getClickedPos();
|
||||
Shop counter = manager.atCounter(player.serverLevel(), pos);
|
||||
if (counter != null && counter.owner.equals(player.getUUID())) {
|
||||
CompoundTag tag = new CompoundTag();
|
||||
tag.putUUID("Shop", counter.id);
|
||||
context.getItemInHand().set(DataComponents.CUSTOM_DATA, CustomData.of(tag));
|
||||
player.displayClientMessage(Component.translatable("cos.link.selected"), true);
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
CompoundTag tag =
|
||||
context
|
||||
.getItemInHand()
|
||||
.getOrDefault(DataComponents.CUSTOM_DATA, CustomData.EMPTY)
|
||||
.copyTag();
|
||||
if (!tag.hasUUID("Shop")) return InteractionResult.FAIL;
|
||||
String result = manager.link(player, tag.getUUID("Shop"), pos, player.isShiftKeyDown());
|
||||
player.displayClientMessage(Component.translatable("cos.link." + result), true);
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package fr.shinuwa.counterofshinuwa.block;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import fr.shinuwa.counterofshinuwa.core.ShopManager;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.*;
|
||||
import net.minecraft.world.*;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.*;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
|
||||
public final class ShopInventoryBlock extends BaseEntityBlock {
|
||||
private final boolean storage;
|
||||
|
||||
public ShopInventoryBlock(boolean storage) {
|
||||
super(Properties.of().strength(2.5f));
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected MapCodec<? extends BaseEntityBlock> codec() {
|
||||
return simpleCodec(p -> new ShopInventoryBlock(storage));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RenderShape getRenderShape(BlockState state) {
|
||||
return RenderShape.MODEL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
return new ShopInventoryBlockEntity(pos, state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPlacedBy(
|
||||
Level level, BlockPos pos, BlockState state, LivingEntity placer, ItemStack stack) {
|
||||
if (level instanceof ServerLevel sl
|
||||
&& placer instanceof ServerPlayer player
|
||||
&& level.getBlockEntity(pos) instanceof ShopInventoryBlockEntity be) {
|
||||
be.owner = player.getUUID();
|
||||
be.setChanged();
|
||||
if (!storage) ShopManager.get(sl.getServer()).create(player, pos);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected InteractionResult useWithoutItem(
|
||||
BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) {
|
||||
if (level instanceof ServerLevel sl
|
||||
&& player instanceof ServerPlayer sp
|
||||
&& level.getBlockEntity(pos) instanceof ShopInventoryBlockEntity be) {
|
||||
if (!storage && !be.owner.equals(player.getUUID())) return InteractionResult.FAIL;
|
||||
sp.openMenu(
|
||||
be,
|
||||
buf -> {
|
||||
buf.writeBlockPos(pos);
|
||||
buf.writeBoolean(storage);
|
||||
buf.writeUUID(be.owner);
|
||||
});
|
||||
}
|
||||
return InteractionResult.sidedSuccess(level.isClientSide);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRemove(
|
||||
BlockState state, Level level, BlockPos pos, BlockState next, boolean moving) {
|
||||
if (state.getBlock() != next.getBlock()) {
|
||||
if (level.getBlockEntity(pos) instanceof ShopInventoryBlockEntity be)
|
||||
Containers.dropContents(level, pos, be);
|
||||
if (level instanceof ServerLevel sl) ShopManager.get(sl.getServer()).invalidate(sl, pos);
|
||||
super.onRemove(state, level, pos, next, moving);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package fr.shinuwa.counterofshinuwa.block;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.*;
|
||||
import fr.shinuwa.counterofshinuwa.menu.ShopMenu;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.*;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.ContainerHelper;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.entity.BaseContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
public final class ShopInventoryBlockEntity extends BaseContainerBlockEntity
|
||||
implements net.minecraft.world.WorldlyContainer {
|
||||
public UUID owner = new UUID(0, 0);
|
||||
private NonNullList<ItemStack> items;
|
||||
|
||||
public ShopInventoryBlockEntity(BlockPos pos, BlockState state) {
|
||||
super(CounterOfShinuwa.INVENTORY.get(), pos, state);
|
||||
items = NonNullList.withSize(storage() ? 27 : 5, ItemStack.EMPTY);
|
||||
}
|
||||
|
||||
public boolean storage() {
|
||||
return getBlockState().is(CounterOfShinuwa.STORAGE.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getContainerSize() {
|
||||
return items.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected NonNullList<ItemStack> getItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setItems(NonNullList<ItemStack> value) {
|
||||
items = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Component getDefaultName() {
|
||||
return Component.translatable(
|
||||
storage()
|
||||
? "block.counter_of_shinuwa.material_storage"
|
||||
: "block.counter_of_shinuwa.counter");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AbstractContainerMenu createMenu(int id, Inventory inv) {
|
||||
return new ShopMenu(id, inv, this, owner);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canPlaceItem(int slot, ItemStack stack) {
|
||||
return !storage() || ShopConfig.material(stack);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int[] getSlotsForFace(Direction face) {
|
||||
return java.util.stream.IntStream.range(0, getContainerSize()).toArray();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canPlaceItemThroughFace(int slot, ItemStack stack, Direction face) {
|
||||
return storage() && ShopConfig.material(stack);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canTakeItemThroughFace(int slot, ItemStack stack, Direction face) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
super.saveAdditional(tag, registries);
|
||||
tag.putUUID("Owner", owner);
|
||||
ContainerHelper.saveAllItems(tag, items, registries);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
super.loadAdditional(tag, registries);
|
||||
if (tag.hasUUID("Owner")) owner = tag.getUUID("Owner");
|
||||
items = NonNullList.withSize(storage() ? 27 : 5, ItemStack.EMPTY);
|
||||
ContainerHelper.loadAllItems(tag, items, registries);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package fr.shinuwa.counterofshinuwa.client;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.renderer.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.neoforged.neoforge.client.event.RenderLevelStageEvent;
|
||||
|
||||
public final class InspectionOverlay {
|
||||
public static boolean requested;
|
||||
private static CompoundTag data;
|
||||
private static long expires;
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.level.Level> dimension;
|
||||
|
||||
public static void update(CompoundTag view) {
|
||||
var level = Minecraft.getInstance().level;
|
||||
if (!view.getBoolean("Inspection") || level == null) return;
|
||||
dimension = level.dimension();
|
||||
data = view.copy();
|
||||
expires = System.nanoTime() + 30_000_000_000L;
|
||||
}
|
||||
|
||||
public static void render(RenderLevelStageEvent event) {
|
||||
if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_PARTICLES
|
||||
|| data == null
|
||||
|| System.nanoTime() > expires) {
|
||||
if (System.nanoTime() > expires) requested = false;
|
||||
return;
|
||||
}
|
||||
var mc = Minecraft.getInstance();
|
||||
if (mc.level == null || !mc.level.dimension().equals(dimension)) return;
|
||||
PoseStack pose = event.getPoseStack();
|
||||
pose.pushPose();
|
||||
var camera = event.getCamera().getPosition();
|
||||
pose.translate(-camera.x, -camera.y, -camera.z);
|
||||
var buffers = mc.renderBuffers().bufferSource();
|
||||
var lines = buffers.getBuffer(RenderType.lines());
|
||||
BlockPos min = BlockPos.of(data.getLong("Min")), max = BlockPos.of(data.getLong("Max"));
|
||||
LevelRenderer.renderLineBox(
|
||||
pose,
|
||||
lines,
|
||||
new AABB(
|
||||
min.getX(), min.getY(), min.getZ(), max.getX() + 1, max.getY() + 1, max.getZ() + 1),
|
||||
.9f,
|
||||
.8f,
|
||||
.2f,
|
||||
.85f);
|
||||
draw(pose, lines, "Incoming", .2f, .5f, 1);
|
||||
draw(pose, lines, "Outgoing", 1, .2f, .2f);
|
||||
draw(pose, lines, "Stations", .2f, 1, .4f);
|
||||
draw(pose, lines, "QueuePositions", .8f, .3f, 1);
|
||||
draw(pose, lines, "Storages", 1, .6f, .2f);
|
||||
for (String key : new String[] {"Entry", "Exit"})
|
||||
if (data.contains(key))
|
||||
LevelRenderer.renderLineBox(
|
||||
pose, lines, new AABB(BlockPos.of(data.getLong(key))), .1f, 1, 1, 1);
|
||||
buffers.endBatch(RenderType.lines());
|
||||
pose.popPose();
|
||||
}
|
||||
|
||||
private static void draw(
|
||||
PoseStack pose,
|
||||
com.mojang.blaze3d.vertex.VertexConsumer lines,
|
||||
String key,
|
||||
float r,
|
||||
float g,
|
||||
float b) {
|
||||
for (long p : data.getLongArray(key))
|
||||
LevelRenderer.renderLineBox(pose, lines, new AABB(BlockPos.of(p)).inflate(.005), r, g, b, 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package fr.shinuwa.counterofshinuwa.client;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.CounterOfShinuwa;
|
||||
import net.neoforged.api.distmarker.Dist;
|
||||
import net.neoforged.bus.api.IEventBus;
|
||||
import net.neoforged.fml.common.Mod;
|
||||
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
|
||||
@Mod(value = CounterOfShinuwa.MODID, dist = Dist.CLIENT)
|
||||
public final class ShopClient {
|
||||
public ShopClient(IEventBus bus) {
|
||||
bus.addListener(ShopClient::screens);
|
||||
NeoForge.EVENT_BUS.addListener(InspectionOverlay::render);
|
||||
}
|
||||
|
||||
private static void screens(RegisterMenuScreensEvent event) {
|
||||
event.register(CounterOfShinuwa.SHOP_MENU.get(), ShopScreen::new);
|
||||
}
|
||||
}
|
||||
231
src/main/java/fr/shinuwa/counterofshinuwa/client/ShopScreen.java
Normal file
231
src/main/java/fr/shinuwa/counterofshinuwa/client/ShopScreen.java
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
package fr.shinuwa.counterofshinuwa.client;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.menu.ShopMenu;
|
||||
import java.util.*;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.nbt.*;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
public final class ShopScreen extends AbstractContainerScreen<ShopMenu> {
|
||||
private final Map<Integer, Button> actions = new HashMap<>();
|
||||
private int page, scroll;
|
||||
private final Map<Integer, Component> tooltips = new HashMap<>();
|
||||
|
||||
public ShopScreen(ShopMenu menu, Inventory inventory, Component title) {
|
||||
super(menu, inventory, title);
|
||||
imageWidth = menu.storage ? 176 : 360;
|
||||
imageHeight = menu.storage ? 176 : 270;
|
||||
inventoryLabelY = menu.storage ? 80 : 172;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
actions.clear();
|
||||
if (menu.storage) return;
|
||||
action(1, "open", 190, 22, 160);
|
||||
action(2, "standard", 190, 47, 78);
|
||||
action(3, "chill", 272, 47, 78);
|
||||
action(4, "confirm", 190, 72, 160);
|
||||
action(5, "close_arrivals", 190, 97, 160);
|
||||
action(6, "force_close", 190, 122, 160);
|
||||
action(7, "refuse", 190, 147, 160);
|
||||
action(8, "deliver", 190, 172, 160);
|
||||
action(9, "withdraw", 190, 197, 160);
|
||||
action(0, "verify", 190, 222, 160);
|
||||
addRenderableWidget(
|
||||
Button.builder(
|
||||
Component.translatable("cos.ui.catalogue"),
|
||||
b -> {
|
||||
page = (page + 1) % 3;
|
||||
scroll = 0;
|
||||
})
|
||||
.bounds(leftPos + 8, topPos + 127, 160, 18)
|
||||
.build());
|
||||
}
|
||||
|
||||
private void action(int id, String name, int x, int y, int w) {
|
||||
actions.put(
|
||||
id,
|
||||
addRenderableWidget(
|
||||
Button.builder(
|
||||
Component.translatable("cos.ui." + name),
|
||||
b -> {
|
||||
if (minecraft != null && minecraft.gameMode != null)
|
||||
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, id);
|
||||
if (id == 0) {
|
||||
page = 2;
|
||||
scroll = 0;
|
||||
InspectionOverlay.requested = true;
|
||||
}
|
||||
})
|
||||
.bounds(leftPos + x, topPos + y, w, 20)
|
||||
.build()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void containerTick() {
|
||||
super.containerTick();
|
||||
if (menu.storage) return;
|
||||
boolean closed = menu.view.getString("State").equals("CLOSED"),
|
||||
preparing = menu.view.getBoolean("Preparing"),
|
||||
order = menu.view.contains("Order");
|
||||
actions.get(1).active = closed;
|
||||
actions.get(2).active = preparing;
|
||||
actions.get(3).active = preparing;
|
||||
actions.get(4).active = preparing;
|
||||
actions.get(5).active = !closed;
|
||||
actions.get(6).active = !closed;
|
||||
actions.get(7).active = order;
|
||||
actions.get(8).active = order;
|
||||
actions.get(9).active = menu.view.getLong("Credit") > 0;
|
||||
if (InspectionOverlay.requested && menu.view.contains("Min"))
|
||||
InspectionOverlay.update(menu.view);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderBg(GuiGraphics g, float partial, int mouseX, int mouseY) {
|
||||
g.fill(leftPos, topPos, leftPos + imageWidth, topPos + imageHeight, 0xff20252b);
|
||||
g.fill(leftPos + 3, topPos + 3, leftPos + imageWidth - 3, topPos + imageHeight - 3, 0xffddd4be);
|
||||
if (!menu.storage)
|
||||
g.fill(
|
||||
leftPos + 181,
|
||||
topPos + 3,
|
||||
leftPos + imageWidth - 3,
|
||||
topPos + imageHeight - 3,
|
||||
0xffb7aa91);
|
||||
for (var slot : menu.slots) {
|
||||
int x = leftPos + slot.x - 1, y = topPos + slot.y - 1;
|
||||
g.fill(x, y, x + 18, y + 18, 0xff514c43);
|
||||
g.fill(x + 1, y + 1, x + 17, y + 17, 0xffaaa393);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderLabels(GuiGraphics g, int mouseX, int mouseY) {
|
||||
tooltips.clear();
|
||||
g.drawString(font, title, 8, 6, 0xff27221c, false);
|
||||
g.drawString(font, playerInventoryTitle, 8, inventoryLabelY, 0xff51483c, false);
|
||||
if (menu.storage) return;
|
||||
CompoundTag v = menu.view;
|
||||
line(g, 22, Component.translatable("cos.state." + v.getString("State")));
|
||||
line(
|
||||
g,
|
||||
34,
|
||||
Component.literal(v.getString("Mode") + " · ")
|
||||
.append(Component.translatable("cos.ui.reputation", v.getInt("Reputation"))));
|
||||
if (page == 1) {
|
||||
ListTag list = v.getList("Catalogue", Tag.TAG_STRING);
|
||||
line(g, 48, Component.translatable("cos.ui.products", list.size()));
|
||||
for (int i = 0; i < 5 && scroll + i < list.size(); i++) {
|
||||
ResourceLocation id = ResourceLocation.tryParse(list.getString(scroll + i));
|
||||
if (id != null)
|
||||
line(g, 62 + i * 12, new ItemStack(BuiltInRegistries.ITEM.get(id)).getHoverName());
|
||||
}
|
||||
} else if (page == 2) {
|
||||
var families = Component.translatable("cos.ui.families", v.getInt("Families"));
|
||||
for (Tag family : v.getList("FamilyNames", Tag.TAG_STRING))
|
||||
families.append(" · ").append(Component.translatable("cos.family." + family.getAsString()));
|
||||
line(g, 48, families);
|
||||
List<Component> messages = new ArrayList<>();
|
||||
for (Tag t : v.getList("Errors", Tag.TAG_STRING))
|
||||
messages.add(Component.translatable("cos.error." + t.getAsString()));
|
||||
for (Tag t : v.getList("Warnings", Tag.TAG_STRING)) {
|
||||
String[] warning = t.getAsString().split(": ", 2);
|
||||
messages.add(
|
||||
warning.length == 2
|
||||
? Component.translatable("cos.warning." + warning[0], warning[1])
|
||||
: Component.literal(t.getAsString()));
|
||||
}
|
||||
if (messages.isEmpty())
|
||||
line(
|
||||
g,
|
||||
64,
|
||||
Component.translatable(v.getBoolean("Valid") ? "cos.ui.valid" : "cos.ui.verify"));
|
||||
for (int i = 0; i < 5 && scroll + i < messages.size(); i++)
|
||||
line(g, 62 + i * 12, messages.get(scroll + i));
|
||||
} else if (v.getBoolean("Preparing")) {
|
||||
line(g, 50, Component.translatable("cos.ui.preparing"));
|
||||
line(
|
||||
g,
|
||||
65,
|
||||
Component.translatable(
|
||||
v.getBoolean("Quantities") ? "cos.ui.real_stock" : "cos.ui.snapshot"));
|
||||
line(
|
||||
g,
|
||||
80,
|
||||
Component.translatable(
|
||||
"cos.ui.multiplier", String.format(Locale.ROOT, "%.2f", v.getDouble("Multiplier"))));
|
||||
line(
|
||||
g,
|
||||
95,
|
||||
Component.translatable("cos.ui.products", v.getList("Catalogue", Tag.TAG_STRING).size()));
|
||||
line(
|
||||
g,
|
||||
110,
|
||||
Component.translatable(
|
||||
v.getString("Mode").equals("CHILL") ? "cos.ui.frozen" : "cos.ui.progression"));
|
||||
} else {
|
||||
line(
|
||||
g,
|
||||
49,
|
||||
Component.translatable("cos.rank." + v.getInt("Rank"))
|
||||
.append(" · ")
|
||||
.append(Component.translatable("cos.ui.queue", v.getInt("Queue"))));
|
||||
ListTag order = v.getList("Order", Tag.TAG_COMPOUND);
|
||||
if (!order.isEmpty()) {
|
||||
for (int i = 0; i < Math.min(3, order.size()); i++) {
|
||||
CompoundTag item = order.getCompound(i);
|
||||
ResourceLocation id = ResourceLocation.tryParse(item.getString("Item"));
|
||||
if (id != null) {
|
||||
ItemStack stack = new ItemStack(BuiltInRegistries.ITEM.get(id));
|
||||
line(
|
||||
g,
|
||||
62 + i * 11,
|
||||
Component.literal(item.getInt("Count") + " × ").append(stack.getHoverName()));
|
||||
}
|
||||
}
|
||||
line(g, 98, Component.translatable("cos.ui.reward", v.getInt("Reward")));
|
||||
line(g, 110, Component.translatable("cos.ui.patience", v.getInt("Patience")));
|
||||
} else line(g, 68, Component.translatable("cos.ui.no_order"));
|
||||
if (v.getLong("Credit") > 0)
|
||||
g.drawString(
|
||||
font,
|
||||
Component.translatable("cos.ui.credit", v.getLong("Credit")),
|
||||
190,
|
||||
250,
|
||||
0xff302c25,
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
private void line(GuiGraphics g, int y, Component text) {
|
||||
tooltips.put(y, text);
|
||||
g.drawString(font, font.substrByWidth(text, 164).getString(), 8, y, 0xff302c25, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double x, double y, double dx, double dy) {
|
||||
if (page != 0) {
|
||||
scroll = Math.max(0, Math.min(2040, scroll - (int) dy));
|
||||
return true;
|
||||
}
|
||||
return super.mouseScrolled(x, y, dx, dy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(GuiGraphics g, int x, int y, float partial) {
|
||||
super.render(g, x, y, partial);
|
||||
renderTooltip(g, x, y);
|
||||
if (x >= leftPos + 8 && x < leftPos + 176)
|
||||
for (var entry : tooltips.entrySet())
|
||||
if (y >= topPos + entry.getKey() && y < topPos + entry.getKey() + 10)
|
||||
g.renderTooltip(font, font.split(entry.getValue(), 240), x, y);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
package fr.shinuwa.counterofshinuwa.compat.vanilla;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.ShopConfig;
|
||||
import fr.shinuwa.counterofshinuwa.core.ShopSession;
|
||||
import fr.shinuwa.counterofshinuwa.production.*;
|
||||
import fr.shinuwa.counterofshinuwa.recipe.*;
|
||||
import fr.shinuwa.counterofshinuwa.station.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.item.crafting.*;
|
||||
import net.minecraft.world.level.block.*;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
public final class VanillaIntegration
|
||||
implements StationProvider, RecipeProvider, StationFamilyProvider {
|
||||
public static String key(String value) {
|
||||
return "counter_of_shinuwa:" + value;
|
||||
}
|
||||
|
||||
private static final Map<Block, StationDefinition> STATIONS =
|
||||
Map.of(
|
||||
Blocks.CRAFTING_TABLE, definition("crafting", "crafting", "manual"),
|
||||
Blocks.FURNACE, definition("furnace", "forge", "furnace"),
|
||||
Blocks.BLAST_FURNACE, definition("blasting", "forge", "furnace"),
|
||||
Blocks.SMOKER, definition("smoking", "cooking", "furnace"),
|
||||
Blocks.STONECUTTER, definition("stonecutting", "stoneworking", "manual"),
|
||||
Blocks.SMITHING_TABLE, definition("smithing", "smithing", "manual"));
|
||||
|
||||
public static StationDefinition definition(String id, String family, String handler) {
|
||||
return new StationDefinition(key(id), key(family), key(handler));
|
||||
}
|
||||
|
||||
public static void register() {
|
||||
VanillaIntegration v = new VanillaIntegration();
|
||||
ProductionRegistry.INSTANCE.register(
|
||||
v, v, v, new Handler(key("manual")), new Handler(key("furnace")));
|
||||
}
|
||||
|
||||
public Optional<StationDefinition> station(BlockState state) {
|
||||
return Optional.ofNullable(STATIONS.get(state.getBlock()));
|
||||
}
|
||||
|
||||
public List<StationDefinition> intrinsicStations() {
|
||||
return List.of(definition("crafting_2x2", "crafting", "manual"));
|
||||
}
|
||||
|
||||
public Set<String> families() {
|
||||
return Set.of(
|
||||
key("crafting"), key("forge"), key("cooking"), key("stoneworking"), key("smithing"));
|
||||
}
|
||||
|
||||
private record Handler(String id) implements ProductionHandler {
|
||||
public void reset() {
|
||||
VanillaProductionHooks.clear();
|
||||
}
|
||||
|
||||
public List<ItemStack> stock(
|
||||
ShopSession s, net.minecraft.world.level.block.entity.BlockEntity be) {
|
||||
if (!(be instanceof net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity f))
|
||||
return List.of();
|
||||
List<ItemStack> result = new ArrayList<>();
|
||||
if (s == null || VanillaProductionHooks.tracked(f, s)) {
|
||||
result.add(f.getItem(0));
|
||||
result.add(f.getItem(1));
|
||||
}
|
||||
if (s != null && ShopProductionTracker.valid(s, f.getItem(2))) result.add(f.getItem(2));
|
||||
return result;
|
||||
}
|
||||
|
||||
public boolean accepts(ShopSession s, List<ItemStack> inputs) {
|
||||
return !inputs.isEmpty()
|
||||
&& inputs.stream()
|
||||
.filter(i -> !i.isEmpty())
|
||||
.allMatch(i -> ShopProductionTracker.ingredient(s, i));
|
||||
}
|
||||
}
|
||||
|
||||
public List<ProductionRecipe> recipes(ServerLevel level) {
|
||||
List<ProductionRecipe> all = new ArrayList<>();
|
||||
for (RecipeHolder<?> holder : level.getRecipeManager().getRecipes()) {
|
||||
Recipe<?> r = holder.value();
|
||||
ItemStack out = r.getResultItem(level.registryAccess());
|
||||
if (out.isEmpty() || ShopConfig.matches(out, ShopConfig.BLACKLIST.get())) continue;
|
||||
// Variant-bearing recipes need an explicit integration: item IDs alone cannot describe them.
|
||||
if (!ItemStack.isSameItemSameComponents(out, new ItemStack(out.getItem()))) continue;
|
||||
String station, family;
|
||||
int ticks = 20;
|
||||
boolean cooking = false;
|
||||
List<Ingredient> ingredients = new ArrayList<>(r.getIngredients());
|
||||
if (r instanceof ShapedRecipe shaped) {
|
||||
station = shaped.getWidth() <= 2 && shaped.getHeight() <= 2 ? "crafting_2x2" : "crafting";
|
||||
family = "crafting";
|
||||
} else if (r instanceof ShapelessRecipe) {
|
||||
station = ingredients.size() <= 4 ? "crafting_2x2" : "crafting";
|
||||
family = "crafting";
|
||||
} else if (r instanceof SmeltingRecipe) {
|
||||
station = "furnace";
|
||||
family = "forge";
|
||||
cooking = true;
|
||||
} else if (r instanceof BlastingRecipe) {
|
||||
station = "blasting";
|
||||
family = "forge";
|
||||
cooking = true;
|
||||
} else if (r instanceof SmokingRecipe) {
|
||||
station = "smoking";
|
||||
family = "cooking";
|
||||
cooking = true;
|
||||
} else if (r instanceof StonecutterRecipe) {
|
||||
station = "stonecutting";
|
||||
family = "stoneworking";
|
||||
} else if (r instanceof SmithingTransformRecipe smith) {
|
||||
station = "smithing";
|
||||
family = "smithing";
|
||||
ingredients =
|
||||
List.of(
|
||||
Ingredient.of(
|
||||
BuiltInRegistries.ITEM.stream()
|
||||
.map(ItemStack::new)
|
||||
.filter(smith::isTemplateIngredient)),
|
||||
Ingredient.of(
|
||||
BuiltInRegistries.ITEM.stream()
|
||||
.map(ItemStack::new)
|
||||
.filter(smith::isBaseIngredient)),
|
||||
Ingredient.of(
|
||||
BuiltInRegistries.ITEM.stream()
|
||||
.map(ItemStack::new)
|
||||
.filter(smith::isAdditionIngredient)));
|
||||
} else continue;
|
||||
if (cooking)
|
||||
ticks =
|
||||
((AbstractCookingRecipe) r).getCookingTime() * (r instanceof SmeltingRecipe ? 1 : 2);
|
||||
List<Set<String>> inputs = new ArrayList<>();
|
||||
Map<String, Integer> remainders = new HashMap<>();
|
||||
boolean safe = true;
|
||||
for (Ingredient in : ingredients) {
|
||||
if (in.isEmpty()) continue;
|
||||
if (!in.isSimple()) {
|
||||
safe = false;
|
||||
break;
|
||||
}
|
||||
Set<String> options = new TreeSet<>();
|
||||
String remainder = null;
|
||||
boolean first = true;
|
||||
for (ItemStack stack : in.getItems()) {
|
||||
options.add(id(stack));
|
||||
ItemStack rest = stack.getCraftingRemainingItem();
|
||||
String restId = rest.isEmpty() ? "" : id(rest);
|
||||
if (first) {
|
||||
remainder = restId;
|
||||
first = false;
|
||||
} else if (!Objects.equals(remainder, restId)) safe = false;
|
||||
}
|
||||
if (options.isEmpty()) {
|
||||
safe = false;
|
||||
break;
|
||||
}
|
||||
if (remainder != null && !remainder.isEmpty()) remainders.merge(remainder, 1, Integer::sum);
|
||||
inputs.add(options);
|
||||
}
|
||||
if (safe && !inputs.isEmpty())
|
||||
all.add(
|
||||
new ProductionRecipe(
|
||||
holder.id().toString(),
|
||||
inputs,
|
||||
id(out),
|
||||
out.getCount(),
|
||||
remainders,
|
||||
key(station),
|
||||
key(family),
|
||||
ticks,
|
||||
cooking));
|
||||
}
|
||||
return List.copyOf(all);
|
||||
}
|
||||
|
||||
public static String id(ItemStack stack) {
|
||||
return BuiltInRegistries.ITEM.getKey(stack.getItem()).toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
package fr.shinuwa.counterofshinuwa.compat.vanilla;
|
||||
|
||||
import static fr.shinuwa.counterofshinuwa.production.ShopProductionTracker.*;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.*;
|
||||
import fr.shinuwa.counterofshinuwa.core.*;
|
||||
import fr.shinuwa.counterofshinuwa.production.*;
|
||||
import fr.shinuwa.counterofshinuwa.production.MenuAccess;
|
||||
import fr.shinuwa.counterofshinuwa.station.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.inventory.*;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.crafting.RecipeHolder;
|
||||
import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity;
|
||||
|
||||
public final class VanillaProductionHooks {
|
||||
private static final ThreadLocal<Extraction> EXTRACTION = new ThreadLocal<>();
|
||||
private static final ThreadLocal<ShopProductionData> CRAFT_STAMP = new ThreadLocal<>();
|
||||
private static final Map<AbstractFurnaceBlockEntity, Batch> BATCHES = new WeakHashMap<>();
|
||||
|
||||
private record Batch(ShopSession session, ItemStack expected, String family) {}
|
||||
|
||||
private record Extraction(
|
||||
ServerPlayer player,
|
||||
AbstractContainerMenu menu,
|
||||
ResultContainer result,
|
||||
List<Integer> inputs,
|
||||
BlockPos station,
|
||||
AbstractFurnaceBlockEntity furnace,
|
||||
ItemStack furnaceBefore) {}
|
||||
|
||||
public static void begin(
|
||||
AbstractContainerMenu menu, net.minecraft.world.entity.player.Player player) {
|
||||
CRAFT_STAMP.remove();
|
||||
if (!(player instanceof ServerPlayer sp)) return;
|
||||
int result = -1;
|
||||
List<Integer> inputs = new ArrayList<>();
|
||||
BlockPos station = sp.blockPosition();
|
||||
if (menu instanceof CraftingMenu) {
|
||||
result = 0;
|
||||
for (int i = 1; i <= 9; i++) inputs.add(i);
|
||||
} else if (menu instanceof InventoryMenu) {
|
||||
result = 0;
|
||||
for (int i = 1; i <= 4; i++) inputs.add(i);
|
||||
} else if (menu instanceof StonecutterMenu) {
|
||||
result = 1;
|
||||
inputs.add(0);
|
||||
} else if (menu instanceof SmithingMenu) {
|
||||
result = 3;
|
||||
inputs.addAll(List.of(0, 1, 2));
|
||||
}
|
||||
if (menu instanceof MenuAccess access) station = access.cosAccess().evaluate((l, p) -> p, null);
|
||||
AbstractFurnaceBlockEntity furnace =
|
||||
menu instanceof AbstractFurnaceMenu
|
||||
&& menu.getSlot(0).container instanceof AbstractFurnaceBlockEntity f
|
||||
? f
|
||||
: null;
|
||||
EXTRACTION.set(
|
||||
new Extraction(
|
||||
sp,
|
||||
menu,
|
||||
result >= 0 && menu.getSlot(result).container instanceof ResultContainer r ? r : null,
|
||||
inputs,
|
||||
station,
|
||||
furnace,
|
||||
furnace == null ? ItemStack.EMPTY : furnace.getItem(0).copy()));
|
||||
}
|
||||
|
||||
public static void end() {
|
||||
CRAFT_STAMP.remove();
|
||||
Extraction e = EXTRACTION.get();
|
||||
EXTRACTION.remove();
|
||||
if (e == null) return;
|
||||
if (e.result != null) {
|
||||
ItemStack preview = e.result.getItem(0);
|
||||
if (!preview.isEmpty()) preview.remove(CounterOfShinuwa.PRODUCTION.get());
|
||||
}
|
||||
if (e.furnace != null) authorize(e);
|
||||
}
|
||||
|
||||
public static void decorate(ResultContainer container, ItemStack result) {
|
||||
Extraction e = EXTRACTION.get();
|
||||
if (e == null || e.result != container || result.isEmpty()) return;
|
||||
CRAFT_STAMP.remove();
|
||||
result.remove(CounterOfShinuwa.PRODUCTION.get());
|
||||
ShopSession s = ShopManager.get(e.player.server).sessionAt(e.player, e.station);
|
||||
if (s == null || !e.menu.stillValid(e.player)) return;
|
||||
RecipeHolder<?> recipe = container.getRecipeUsed();
|
||||
if (recipe == null) return;
|
||||
var known =
|
||||
ProductionRegistry.INSTANCE.recipes(e.player.serverLevel()).stream()
|
||||
.filter(r -> r.id().equals(recipe.id().toString()))
|
||||
.findFirst();
|
||||
if (known.isEmpty() || !s.structure.stationTypes().contains(known.get().station())) return;
|
||||
String family = known.get().family(), handler = VanillaIntegration.key("manual");
|
||||
if (!(e.menu instanceof InventoryMenu)) {
|
||||
StationDefinition station = s.structure.stations().get(e.station);
|
||||
if (station == null || !station.family().equals(family)) return;
|
||||
handler = station.handler();
|
||||
}
|
||||
List<ItemStack> inputs =
|
||||
e.inputs.stream()
|
||||
.map(i -> e.menu.getSlot(i).container.getItem(e.menu.getSlot(i).getContainerSlot()))
|
||||
.filter(i -> !i.isEmpty())
|
||||
.toList();
|
||||
ProductionHandler h = ProductionRegistry.INSTANCE.handler(handler);
|
||||
if (h != null
|
||||
&& ShopProductionTracker.produced(
|
||||
new ProductionContext(s, e.player.getUUID(), e.station, handler, family),
|
||||
inputs,
|
||||
result)) CRAFT_STAMP.set(result.get(CounterOfShinuwa.PRODUCTION.get()));
|
||||
else CRAFT_STAMP.remove();
|
||||
}
|
||||
|
||||
public static net.minecraft.core.NonNullList<ItemStack> remainders(
|
||||
net.minecraft.core.NonNullList<ItemStack> items) {
|
||||
Extraction e = EXTRACTION.get();
|
||||
ShopProductionData stamp = CRAFT_STAMP.get();
|
||||
if (e == null || stamp == null) return items;
|
||||
var session = ShopManager.get(e.player.server).sessionAt(e.player, e.station);
|
||||
if (session == null || !stamp.valid(session)) return items;
|
||||
// Called before input consumption, once per actual crafting operation, never for previews.
|
||||
for (int slot : e.inputs)
|
||||
if (!e.menu.getSlot(slot).getItem().isEmpty()
|
||||
&& !ingredient(session, e.menu.getSlot(slot).getItem())) return items;
|
||||
for (ItemStack item : items)
|
||||
if (!item.isEmpty()) item.set(CounterOfShinuwa.PRODUCTION.get(), stamp);
|
||||
return items;
|
||||
}
|
||||
|
||||
private static void authorize(Extraction e) {
|
||||
AbstractFurnaceBlockEntity f = e.furnace;
|
||||
ItemStack now = f.getItem(0);
|
||||
Batch previous = BATCHES.get(f);
|
||||
if (ItemStack.matches(now, e.furnaceBefore)) return;
|
||||
ShopSession s = ShopManager.get(e.player.server).sessionAt(e.player, f.getBlockPos());
|
||||
if (now.isEmpty()) {
|
||||
BATCHES.remove(f);
|
||||
return;
|
||||
}
|
||||
boolean continuous =
|
||||
previous != null
|
||||
&& previous.session == s
|
||||
&& ItemStack.matches(previous.expected, e.furnaceBefore);
|
||||
if (s == null || (!e.furnaceBefore.isEmpty() && !continuous) || !ingredient(s, now)) {
|
||||
BATCHES.remove(f);
|
||||
return;
|
||||
}
|
||||
StationDefinition station = s.structure.stations().get(f.getBlockPos());
|
||||
if (station == null || !station.handler().equals(VanillaIntegration.key("furnace"))) return;
|
||||
BATCHES.put(f, new Batch(s, now.copy(), station.family()));
|
||||
if (!continuous) ((fr.shinuwa.counterofshinuwa.mixin.FurnaceAccess) f).cosResetProgress(0);
|
||||
}
|
||||
|
||||
public static void inventoryChanged(AbstractFurnaceBlockEntity furnace) {
|
||||
Extraction e = EXTRACTION.get();
|
||||
if (e == null || e.furnace != furnace) BATCHES.remove(furnace);
|
||||
}
|
||||
|
||||
public static void automation(net.minecraft.world.Container destination) {
|
||||
if (destination instanceof AbstractFurnaceBlockEntity f) BATCHES.remove(f);
|
||||
}
|
||||
|
||||
public static ItemStack furnaceResult(AbstractFurnaceBlockEntity f, ItemStack result) {
|
||||
result.remove(CounterOfShinuwa.PRODUCTION.get());
|
||||
Batch batch = BATCHES.get(f);
|
||||
if (batch == null || !(f.getLevel() instanceof net.minecraft.server.level.ServerLevel level))
|
||||
return result;
|
||||
ShopSession active = ShopManager.get(level.getServer()).session(batch.session.shop.id);
|
||||
if (active != batch.session
|
||||
|| !ItemStack.matches(batch.expected, f.getItem(0))
|
||||
|| !ingredient(active, f.getItem(0))) {
|
||||
BATCHES.remove(f);
|
||||
return result;
|
||||
}
|
||||
ItemStack fuel = f.getItem(1);
|
||||
if (!fuel.isEmpty() && !ingredient(active, fuel)) {
|
||||
BATCHES.remove(f);
|
||||
return result;
|
||||
}
|
||||
ShopProductionData stamp =
|
||||
new ShopProductionData(
|
||||
active.shop.id,
|
||||
active.id,
|
||||
active.shop.owner,
|
||||
VanillaIntegration.key("furnace"),
|
||||
batch.family);
|
||||
ItemStack old = f.getItem(2);
|
||||
if (!old.isEmpty() && !stamp.equals(old.get(CounterOfShinuwa.PRODUCTION.get()))) {
|
||||
BATCHES.remove(f);
|
||||
return result;
|
||||
}
|
||||
result.set(CounterOfShinuwa.PRODUCTION.get(), stamp);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void cooked(AbstractFurnaceBlockEntity f, boolean success) {
|
||||
Batch batch = BATCHES.get(f);
|
||||
if (success && batch != null) {
|
||||
if (f.getItem(0).isEmpty()) BATCHES.remove(f);
|
||||
else BATCHES.put(f, new Batch(batch.session, f.getItem(0).copy(), batch.family));
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean tracked(AbstractFurnaceBlockEntity f, ShopSession s) {
|
||||
Batch b = BATCHES.get(f);
|
||||
return b != null && b.session == s && ItemStack.matches(b.expected, f.getItem(0));
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
BATCHES.clear();
|
||||
EXTRACTION.remove();
|
||||
CRAFT_STAMP.remove();
|
||||
}
|
||||
}
|
||||
33
src/main/java/fr/shinuwa/counterofshinuwa/core/Delivery.java
Normal file
33
src/main/java/fr/shinuwa/counterofshinuwa/core/Delivery.java
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package fr.shinuwa.counterofshinuwa.core;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.production.ShopProductionTracker;
|
||||
import fr.shinuwa.counterofshinuwa.recipe.ItemKeys;
|
||||
import java.util.*;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
public final class Delivery {
|
||||
private Delivery() {}
|
||||
|
||||
/** Validate the entire transaction before touching any slot. */
|
||||
public static boolean consume(
|
||||
Container container, ShopSession session, Map<String, Integer> products) {
|
||||
Map<String, Integer> remaining = new HashMap<>(products);
|
||||
int[] take = new int[container.getContainerSize()];
|
||||
for (int i = 0; i < take.length; i++) {
|
||||
ItemStack stack = container.getItem(i);
|
||||
String id = ItemKeys.id(stack);
|
||||
if (!ShopProductionTracker.valid(session, stack)) continue;
|
||||
ItemStack ordinary = stack.copy();
|
||||
ordinary.remove(fr.shinuwa.counterofshinuwa.CounterOfShinuwa.PRODUCTION.get());
|
||||
if (!ItemStack.isSameItemSameComponents(ordinary, new ItemStack(stack.getItem()))) continue;
|
||||
int wanted = remaining.getOrDefault(id, 0);
|
||||
take[i] = Math.min(stack.getCount(), wanted);
|
||||
remaining.put(id, wanted - take[i]);
|
||||
}
|
||||
if (remaining.values().stream().anyMatch(n -> n > 0)) return false;
|
||||
for (int i = 0; i < take.length; i++) if (take[i] > 0) container.removeItem(i, take[i]);
|
||||
container.setChanged();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
106
src/main/java/fr/shinuwa/counterofshinuwa/core/Shop.java
Normal file
106
src/main/java/fr/shinuwa/counterofshinuwa/core/Shop.java
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package fr.shinuwa.counterofshinuwa.core;
|
||||
|
||||
import java.util.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.nbt.*;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
public final class Shop {
|
||||
public final UUID id, owner;
|
||||
public final ResourceLocation dimension;
|
||||
public final BlockPos counter;
|
||||
public BlockPos cornerA, cornerB, entry, exit;
|
||||
public final Set<BlockPos> storages = new LinkedHashSet<>();
|
||||
public long credit;
|
||||
|
||||
public Shop(UUID id, UUID owner, ResourceLocation dimension, BlockPos counter) {
|
||||
this.id = id;
|
||||
this.owner = owner;
|
||||
this.dimension = dimension;
|
||||
this.counter = counter.immutable();
|
||||
}
|
||||
|
||||
public BlockPos min() {
|
||||
return cornerA == null || cornerB == null
|
||||
? counter
|
||||
: new BlockPos(
|
||||
Math.min(cornerA.getX(), cornerB.getX()),
|
||||
Math.min(cornerA.getY(), cornerB.getY()),
|
||||
Math.min(cornerA.getZ(), cornerB.getZ()));
|
||||
}
|
||||
|
||||
public BlockPos max() {
|
||||
return cornerA == null || cornerB == null
|
||||
? counter
|
||||
: new BlockPos(
|
||||
Math.max(cornerA.getX(), cornerB.getX()),
|
||||
Math.max(cornerA.getY(), cornerB.getY()),
|
||||
Math.max(cornerA.getZ(), cornerB.getZ()));
|
||||
}
|
||||
|
||||
public boolean contains(BlockPos p) {
|
||||
BlockPos a = min(), b = max();
|
||||
return cornerA != null
|
||||
&& cornerB != null
|
||||
&& p.getX() >= a.getX()
|
||||
&& p.getX() <= b.getX()
|
||||
&& p.getY() >= a.getY()
|
||||
&& p.getY() <= b.getY()
|
||||
&& p.getZ() >= a.getZ()
|
||||
&& p.getZ() <= b.getZ();
|
||||
}
|
||||
|
||||
public boolean overlaps(Shop other) {
|
||||
if (!dimension.equals(other.dimension)
|
||||
|| cornerA == null
|
||||
|| cornerB == null
|
||||
|| other.cornerA == null
|
||||
|| other.cornerB == null) return false;
|
||||
BlockPos a = min(), b = max(), c = other.min(), d = other.max();
|
||||
return a.getX() <= d.getX()
|
||||
&& b.getX() >= c.getX()
|
||||
&& a.getY() <= d.getY()
|
||||
&& b.getY() >= c.getY()
|
||||
&& a.getZ() <= d.getZ()
|
||||
&& b.getZ() >= c.getZ();
|
||||
}
|
||||
|
||||
public CompoundTag save() {
|
||||
CompoundTag t = new CompoundTag();
|
||||
t.putUUID("Id", id);
|
||||
t.putUUID("Owner", owner);
|
||||
t.putString("Dimension", dimension.toString());
|
||||
t.putLong("Counter", counter.asLong());
|
||||
putPos(t, "A", cornerA);
|
||||
putPos(t, "B", cornerB);
|
||||
putPos(t, "Entry", entry);
|
||||
putPos(t, "Exit", exit);
|
||||
t.putLongArray("Storages", storages.stream().mapToLong(BlockPos::asLong).toArray());
|
||||
t.putLong("Credit", credit);
|
||||
return t;
|
||||
}
|
||||
|
||||
private static void putPos(CompoundTag t, String key, BlockPos p) {
|
||||
if (p != null) t.putLong(key, p.asLong());
|
||||
}
|
||||
|
||||
public static Shop load(CompoundTag t) {
|
||||
Shop s =
|
||||
new Shop(
|
||||
t.getUUID("Id"),
|
||||
t.getUUID("Owner"),
|
||||
ResourceLocation.parse(t.getString("Dimension")),
|
||||
BlockPos.of(t.getLong("Counter")));
|
||||
s.cornerA = pos(t, "A");
|
||||
s.cornerB = pos(t, "B");
|
||||
s.entry = pos(t, "Entry");
|
||||
s.exit = pos(t, "Exit");
|
||||
for (long p : t.getLongArray("Storages")) s.storages.add(BlockPos.of(p));
|
||||
s.credit = Math.max(0, t.getLong("Credit"));
|
||||
return s;
|
||||
}
|
||||
|
||||
private static BlockPos pos(CompoundTag t, String key) {
|
||||
return t.contains(key) ? BlockPos.of(t.getLong(key)) : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
package fr.shinuwa.counterofshinuwa.core;
|
||||
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import fr.shinuwa.counterofshinuwa.CounterOfShinuwa;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||
|
||||
/** Owner recovery for shops whose counter has been destroyed. */
|
||||
public final class ShopCommands {
|
||||
public static void register(RegisterCommandsEvent event) {
|
||||
event
|
||||
.getDispatcher()
|
||||
.register(
|
||||
Commands.literal("countershop")
|
||||
.then(
|
||||
Commands.literal("list")
|
||||
.executes(
|
||||
context -> {
|
||||
var player = context.getSource().getPlayerOrException();
|
||||
var manager = ShopManager.get(player.server);
|
||||
for (var shop : manager.data.shops.values())
|
||||
if (shop.owner.equals(player.getUUID()))
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
shop.id
|
||||
+ " · "
|
||||
+ shop.dimension
|
||||
+ " · "
|
||||
+ shop.counter.toShortString()
|
||||
+ " · "
|
||||
+ shop.credit
|
||||
+ " emeralds"),
|
||||
false);
|
||||
return 1;
|
||||
}))
|
||||
.then(
|
||||
Commands.literal("forget")
|
||||
.then(
|
||||
Commands.argument("shop", StringArgumentType.word())
|
||||
.executes(
|
||||
context -> {
|
||||
var player = context.getSource().getPlayerOrException();
|
||||
var manager = ShopManager.get(player.server);
|
||||
UUID id;
|
||||
try {
|
||||
id =
|
||||
UUID.fromString(
|
||||
StringArgumentType.getString(context, "shop"));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return 0;
|
||||
}
|
||||
var shop = manager.data.shops.get(id);
|
||||
if (shop == null
|
||||
|| !shop.owner.equals(player.getUUID())
|
||||
|| manager.session(id) != null) return 0;
|
||||
var level = manager.level(shop);
|
||||
if (level == null
|
||||
|| !level.hasChunkAt(shop.counter)
|
||||
|| level
|
||||
.getBlockState(shop.counter)
|
||||
.is(CounterOfShinuwa.COUNTER.get())) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"cos.message.forget_counter"));
|
||||
return 0;
|
||||
}
|
||||
manager.withdraw(shop);
|
||||
if (shop.credit > 0) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"cos.message.forget_credit"));
|
||||
return 0;
|
||||
}
|
||||
manager.data.shops.remove(id);
|
||||
manager.data.setDirty();
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() -> Component.translatable("cos.message.forgotten"),
|
||||
false);
|
||||
return 1;
|
||||
}))));
|
||||
}
|
||||
}
|
||||
120
src/main/java/fr/shinuwa/counterofshinuwa/core/ShopEvents.java
Normal file
120
src/main/java/fr/shinuwa/counterofshinuwa/core/ShopEvents.java
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package fr.shinuwa.counterofshinuwa.core;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.block.ShopInventoryBlockEntity;
|
||||
import fr.shinuwa.counterofshinuwa.integration.easynpc.EasyNpcAdapter;
|
||||
import fr.shinuwa.counterofshinuwa.station.ProductionRegistry;
|
||||
import net.minecraft.server.level.*;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.neoforge.event.OnDatapackSyncEvent;
|
||||
import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent;
|
||||
import net.neoforged.neoforge.event.entity.player.*;
|
||||
import net.neoforged.neoforge.event.level.*;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
|
||||
public final class ShopEvents {
|
||||
@SubscribeEvent
|
||||
public static void tick(ServerTickEvent.Post event) {
|
||||
ShopManager.get(event.getServer()).tick();
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void stop(ServerStoppingEvent event) {
|
||||
ShopManager.stop(event.getServer());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void logout(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||
if (event.getEntity() instanceof ServerPlayer p)
|
||||
ShopManager.get(p.server).closeOwner(p.getUUID());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void respawn(PlayerEvent.PlayerRespawnEvent event) {
|
||||
if (event.getEntity() instanceof ServerPlayer p)
|
||||
ShopManager.get(p.server).closeOwner(p.getUUID());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void reload(OnDatapackSyncEvent event) {
|
||||
if (event.getPlayer() == null) {
|
||||
ShopManager.get(event.getPlayerList().getServer()).closeAll();
|
||||
ProductionRegistry.INSTANCE.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void broken(BlockEvent.BreakEvent event) {
|
||||
if (!(event.getLevel() instanceof ServerLevel level)) return;
|
||||
if (level.getBlockEntity(event.getPos()) instanceof ShopInventoryBlockEntity be
|
||||
&& !be.owner.equals(event.getPlayer().getUUID())) {
|
||||
event.setCanceled(true);
|
||||
return;
|
||||
}
|
||||
ShopManager.get(level.getServer()).invalidate(level, event.getPos());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void placed(BlockEvent.EntityPlaceEvent event) {
|
||||
if (event.getLevel() instanceof ServerLevel level)
|
||||
ShopManager.get(level.getServer()).invalidate(level, event.getPos());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void neighbors(BlockEvent.NeighborNotifyEvent event) {
|
||||
if (event.getLevel() instanceof ServerLevel level)
|
||||
ShopManager.get(level.getServer()).invalidate(level, event.getPos());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void chunk(ChunkEvent.Unload event) {
|
||||
if (event.getLevel() instanceof ServerLevel level) {
|
||||
var manager = ShopManager.get(level.getServer());
|
||||
var pos = event.getChunk().getPos();
|
||||
for (var s : manager.sessions())
|
||||
if (s.shop.dimension.equals(level.dimension().location())
|
||||
&& pos.x >= s.shop.min().getX() >> 4
|
||||
&& pos.x <= s.shop.max().getX() >> 4
|
||||
&& pos.z >= s.shop.min().getZ() >> 4
|
||||
&& pos.z <= s.shop.max().getZ() >> 4) manager.close(s, false);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void stale(EntityJoinLevelEvent event) {
|
||||
if (event.getLevel() instanceof ServerLevel level
|
||||
&& event.getEntity() instanceof Mob mob
|
||||
&& mob.getPersistentData().hasUUID("cos_session")) {
|
||||
var manager = ShopManager.get(level.getServer());
|
||||
var tag = mob.getPersistentData();
|
||||
var s = tag.hasUUID("cos_shop") ? manager.session(tag.getUUID("cos_shop")) : null;
|
||||
if (s == null || !s.id.equals(tag.getUUID("cos_session"))) {
|
||||
event.setCanceled(true);
|
||||
new EasyNpcAdapter().remove(mob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void interact(PlayerInteractEvent.RightClickBlock event) {
|
||||
if (!(event.getLevel() instanceof ServerLevel level)) return;
|
||||
for (var s : ShopManager.get(level.getServer()).sessions())
|
||||
if (s.shop.dimension.equals(level.dimension().location())
|
||||
&& s.structure.stations().containsKey(event.getPos())
|
||||
&& !s.shop.owner.equals(event.getEntity().getUUID())) {
|
||||
event.setCanceled(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void customerInteract(PlayerInteractEvent.EntityInteract event) {
|
||||
if (event.getTarget().getPersistentData().hasUUID("cos_session")) event.setCanceled(true);
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void customerInteractSpecific(PlayerInteractEvent.EntityInteractSpecific event) {
|
||||
if (event.getTarget().getPersistentData().hasUUID("cos_session")) event.setCanceled(true);
|
||||
}
|
||||
}
|
||||
505
src/main/java/fr/shinuwa/counterofshinuwa/core/ShopManager.java
Normal file
505
src/main/java/fr/shinuwa/counterofshinuwa/core/ShopManager.java
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
package fr.shinuwa.counterofshinuwa.core;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.*;
|
||||
import fr.shinuwa.counterofshinuwa.block.ShopInventoryBlockEntity;
|
||||
import fr.shinuwa.counterofshinuwa.customer.*;
|
||||
import fr.shinuwa.counterofshinuwa.gameplay.*;
|
||||
import fr.shinuwa.counterofshinuwa.integration.easynpc.EasyNpcAdapter;
|
||||
import fr.shinuwa.counterofshinuwa.menu.ShopMenu;
|
||||
import fr.shinuwa.counterofshinuwa.network.ShopNetwork;
|
||||
import fr.shinuwa.counterofshinuwa.production.*;
|
||||
import fr.shinuwa.counterofshinuwa.recipe.*;
|
||||
import fr.shinuwa.counterofshinuwa.station.ProductionRegistry;
|
||||
import fr.shinuwa.counterofshinuwa.validation.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.*;
|
||||
import net.minecraft.nbt.*;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.*;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.*;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.neoforged.neoforge.network.PacketDistributor;
|
||||
|
||||
public final class ShopManager {
|
||||
private static final Map<MinecraftServer, ShopManager> INSTANCES = new WeakHashMap<>();
|
||||
|
||||
public static ShopManager get(MinecraftServer server) {
|
||||
return INSTANCES.computeIfAbsent(server, ShopManager::new);
|
||||
}
|
||||
|
||||
public static void stop(MinecraftServer server) {
|
||||
ShopManager m = INSTANCES.remove(server);
|
||||
if (m != null) m.closeAll();
|
||||
ProductionRegistry.INSTANCE.resetProduction();
|
||||
ProductionRegistry.INSTANCE.invalidate();
|
||||
}
|
||||
|
||||
private final MinecraftServer server;
|
||||
public final ShopSavedData data;
|
||||
private final Map<UUID, ShopSession> sessions = new HashMap<>();
|
||||
private final Map<UUID, ShopValidationResult> reports = new HashMap<>();
|
||||
private final Map<Set<String>, RecipeResolver> resolvers = new HashMap<>();
|
||||
private final Set<UUID> dirty = new HashSet<>();
|
||||
private final Map<UUID, String> messages = new HashMap<>();
|
||||
private final CustomerManager customers = new CustomerManager(new EasyNpcAdapter());
|
||||
|
||||
private ShopManager(MinecraftServer server) {
|
||||
this.server = server;
|
||||
data = ShopSavedData.get(server);
|
||||
}
|
||||
|
||||
public int reputation(UUID owner) {
|
||||
return data.reputation(owner);
|
||||
}
|
||||
|
||||
public ShopSession session(UUID shop) {
|
||||
return sessions.get(shop);
|
||||
}
|
||||
|
||||
public Collection<ShopSession> sessions() {
|
||||
return List.copyOf(sessions.values());
|
||||
}
|
||||
|
||||
public ServerLevel level(Shop shop) {
|
||||
return server.getLevel(ResourceKey.create(Registries.DIMENSION, shop.dimension));
|
||||
}
|
||||
|
||||
public Shop atCounter(ServerLevel level, BlockPos pos) {
|
||||
return data.shops.values().stream()
|
||||
.filter(s -> s.dimension.equals(level.dimension().location()) && s.counter.equals(pos))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public Shop create(ServerPlayer owner, BlockPos pos) {
|
||||
Shop existing = atCounter(owner.serverLevel(), pos);
|
||||
if (existing != null) return existing;
|
||||
Shop s =
|
||||
new Shop(UUID.randomUUID(), owner.getUUID(), owner.level().dimension().location(), pos);
|
||||
data.shops.put(s.id, s);
|
||||
data.setDirty();
|
||||
return s;
|
||||
}
|
||||
|
||||
public ShopSession sessionAt(ServerPlayer player, BlockPos station) {
|
||||
if (station == null) return null;
|
||||
return sessions.values().stream()
|
||||
.filter(
|
||||
s ->
|
||||
s.shop.owner.equals(player.getUUID())
|
||||
&& s.shop.dimension.equals(player.level().dimension().location())
|
||||
&& s.shop.contains(station)
|
||||
&& s.shop.contains(player.blockPosition())
|
||||
&& (s.state == ShopSession.State.OPEN
|
||||
|| s.state == ShopSession.State.ARRIVALS_CLOSED))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public String link(ServerPlayer player, UUID id, BlockPos pos, boolean secondary) {
|
||||
Shop shop = data.shops.get(id);
|
||||
if (shop == null
|
||||
|| !shop.owner.equals(player.getUUID())
|
||||
|| !shop.dimension.equals(player.level().dimension().location())) return "denied";
|
||||
if (sessions.containsKey(id)) return "close_first";
|
||||
var state = player.level().getBlockState(pos);
|
||||
boolean taken =
|
||||
data.shops.values().stream()
|
||||
.anyMatch(
|
||||
other ->
|
||||
!other.id.equals(id)
|
||||
&& other.dimension.equals(shop.dimension)
|
||||
&& (other.storages.contains(pos)
|
||||
|| pos.equals(other.cornerA)
|
||||
|| pos.equals(other.cornerB)
|
||||
|| pos.equals(other.entry)
|
||||
|| pos.equals(other.exit)));
|
||||
if (taken) return "already_linked";
|
||||
if (state.is(CounterOfShinuwa.MARKER.get())) {
|
||||
if (secondary || shop.cornerA != null && shop.cornerB == null) shop.cornerB = pos.immutable();
|
||||
else shop.cornerA = pos.immutable();
|
||||
} else if (state.is(CounterOfShinuwa.ACCESS.get())) {
|
||||
if (secondary) shop.exit = pos.immutable();
|
||||
else {
|
||||
shop.entry = pos.immutable();
|
||||
if (shop.exit == null) shop.exit = pos.immutable();
|
||||
}
|
||||
} else if (state.is(CounterOfShinuwa.STORAGE.get())
|
||||
&& player.level().getBlockEntity(pos) instanceof ShopInventoryBlockEntity be) {
|
||||
if (!be.owner.equals(player.getUUID())) return "denied";
|
||||
if (secondary) shop.storages.remove(pos);
|
||||
else shop.storages.add(pos.immutable());
|
||||
} else return "invalid";
|
||||
reports.remove(id);
|
||||
data.setDirty();
|
||||
return "linked";
|
||||
}
|
||||
|
||||
public ShopValidationResult verify(Shop shop) {
|
||||
ServerLevel level = level(shop);
|
||||
var report = ShopValidator.scan(level, shop, data.shops.values());
|
||||
reports.put(shop.id, report);
|
||||
return report;
|
||||
}
|
||||
|
||||
public void invalidate(ServerLevel level, BlockPos pos) {
|
||||
for (Shop shop : data.shops.values())
|
||||
if (shop.dimension.equals(level.dimension().location())
|
||||
&& (shop.contains(pos) || shop.counter.equals(pos))) {
|
||||
reports.remove(shop.id);
|
||||
dirty.add(shop.id);
|
||||
}
|
||||
}
|
||||
|
||||
private RecipeResolver resolver(ServerLevel level, ShopValidationResult report) {
|
||||
var cached = resolvers.get(report.stationTypes());
|
||||
if (cached != null) return cached;
|
||||
var recipes =
|
||||
ProductionRegistry.INSTANCE.recipes(level).stream()
|
||||
.filter(r -> report.stationTypes().contains(r.station()))
|
||||
.toList();
|
||||
Map<String, Integer> fuels = new TreeMap<>();
|
||||
for (Item item : BuiltInRegistries.ITEM) {
|
||||
ItemStack stack = new ItemStack(item);
|
||||
int time = stack.getBurnTime(null);
|
||||
if (time > 0 && ShopConfig.material(stack))
|
||||
fuels.put(BuiltInRegistries.ITEM.getKey(item).toString(), time);
|
||||
}
|
||||
var resolver = new RecipeResolver(recipes, fuels, 12, 12000);
|
||||
resolvers.put(report.stationTypes(), resolver);
|
||||
return resolver;
|
||||
}
|
||||
|
||||
private List<String> catalogue(RecipeResolver resolver, Map<String, Integer> stock) {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (String item : resolver.products())
|
||||
if (resolver.plan(Map.of(item, 1), stock).isPresent()) result.add(item);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Map<String, Integer> types(Map<String, Integer> stock) {
|
||||
Map<String, Integer> copy = new HashMap<>();
|
||||
stock.forEach(
|
||||
(k, v) -> {
|
||||
if (v > 0) copy.put(k, 4096);
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
public String open(ServerPlayer player, Shop shop, ShopGameplayMode mode) {
|
||||
if (!shop.owner.equals(player.getUUID())) return "denied";
|
||||
if (sessions.values().stream().anyMatch(s -> s.shop.owner.equals(shop.owner)))
|
||||
return "already_open";
|
||||
var report = verify(shop);
|
||||
if (!report.valid()) return "invalid_shop";
|
||||
var rules = ShopGameplayRules.forMode(mode, ShopConfig.rules());
|
||||
var resolver = resolver(player.serverLevel(), report);
|
||||
var stock = ShopStock.collect(player.serverLevel(), shop, null, report);
|
||||
boolean quantities = rules.usesQuantities(reputation(shop.owner));
|
||||
var available = quantities ? stock : types(stock);
|
||||
List<String> products = catalogue(resolver, available);
|
||||
if (products.isEmpty()) return "empty_catalogue";
|
||||
ShopSession session =
|
||||
new ShopSession(
|
||||
shop,
|
||||
mode,
|
||||
rules,
|
||||
report,
|
||||
resolver,
|
||||
products,
|
||||
stock,
|
||||
quantities,
|
||||
player.serverLevel().getGameTime());
|
||||
sessions.put(shop.id, session);
|
||||
return "opened";
|
||||
}
|
||||
|
||||
public Optional<ShopOrder> generateOrder(
|
||||
ServerLevel level, ShopSession s, UUID customer, long now) {
|
||||
var stock =
|
||||
s.quantities ? ShopStock.collect(level, s.shop, s, s.structure) : types(s.materialSnapshot);
|
||||
var ready =
|
||||
s.quantities
|
||||
? ShopStock.finished(level, s.shop, s, s.structure)
|
||||
: Map.<String, Integer>of();
|
||||
if (s.quantities)
|
||||
s.catalogue =
|
||||
s.resolver.products().stream()
|
||||
.filter(item -> s.resolver.plan(Map.of(item, 1), stock, ready).isPresent())
|
||||
.toList();
|
||||
List<String> options = new ArrayList<>(s.catalogue);
|
||||
Collections.shuffle(options);
|
||||
Map<String, Integer> requested = new LinkedHashMap<>();
|
||||
RecipeResolver.Plan selected = null;
|
||||
int lineLimit = 1 + level.random.nextInt(3);
|
||||
int slots = 0;
|
||||
for (String item : options) {
|
||||
Item mcItem = BuiltInRegistries.ITEM.get(ResourceLocation.parse(item));
|
||||
int maxStack = new ItemStack(mcItem).getMaxStackSize();
|
||||
int max = Math.min(maxStack * (5 - slots), s.rules.maxQuantity(reputation(s.shop.owner)));
|
||||
int quantity = 1 + level.random.nextInt(Math.max(1, max));
|
||||
for (int q = quantity; q >= 1; q--) {
|
||||
Map<String, Integer> candidate = new LinkedHashMap<>(requested);
|
||||
candidate.put(item, q);
|
||||
var plan = s.resolver.plan(candidate, stock, ready);
|
||||
if (plan.isPresent()) {
|
||||
requested = candidate;
|
||||
selected = plan.get();
|
||||
slots += (q + maxStack - 1) / maxStack;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (requested.size() >= lineLimit || slots == 5) break;
|
||||
}
|
||||
if (selected == null) return Optional.empty();
|
||||
int reward =
|
||||
ShopPricing.reward(
|
||||
requested,
|
||||
ShopConfig::price,
|
||||
selected.steps().size(),
|
||||
ShopConfig.COMPLEXITY.get(),
|
||||
s.rules.rewardMultiplier());
|
||||
return Optional.of(
|
||||
new ShopOrder(
|
||||
UUID.randomUUID(),
|
||||
customer,
|
||||
requested,
|
||||
reward,
|
||||
now + s.rules.patienceTicks(selected.ticks())));
|
||||
}
|
||||
|
||||
public void tick() {
|
||||
for (ShopSession s : new ArrayList<>(sessions.values())) {
|
||||
ServerLevel level = level(s.shop);
|
||||
ServerPlayer owner = server.getPlayerList().getPlayer(s.shop.owner);
|
||||
if (level == null || owner == null || !ShopValidator.loaded(level, s.shop)) {
|
||||
close(s, false);
|
||||
continue;
|
||||
}
|
||||
long now = level.getGameTime();
|
||||
if (dirty.contains(s.shop.id) && now % 20 == 0) {
|
||||
dirty.remove(s.shop.id);
|
||||
var report = verify(s.shop);
|
||||
if (!report.valid()
|
||||
|| !report.stations().equals(s.structure.stations())
|
||||
|| !report.incoming().equals(s.structure.incoming())
|
||||
|| !report.outgoing().equals(s.structure.outgoing())) {
|
||||
close(s, false);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (now % 10 != 0) continue;
|
||||
if (s.order != null && now >= s.order.deadline()) finish(s, OrderOutcome.TIMEOUT);
|
||||
customers.tick(level, s, this, now);
|
||||
if (s.state == ShopSession.State.ARRIVALS_CLOSED && s.customers.isEmpty()) close(s, false);
|
||||
}
|
||||
}
|
||||
|
||||
public void close(ShopSession s, boolean forced) {
|
||||
if (!sessions.containsKey(s.shop.id)) return;
|
||||
if (forced && s.customers.stream().anyMatch(c -> c.state != ShopCustomer.State.LEAVING))
|
||||
reputation(s, OrderOutcome.FORCE_CLOSE);
|
||||
s.state = forced ? ShopSession.State.FORCE_CLOSED : ShopSession.State.CLOSED;
|
||||
ServerLevel level = level(s.shop);
|
||||
if (level != null) customers.clear(level, s);
|
||||
s.order = null;
|
||||
sessions.remove(s.shop.id);
|
||||
}
|
||||
|
||||
public void closeOwner(UUID owner) {
|
||||
for (var s : new ArrayList<>(sessions.values()))
|
||||
if (s.shop.owner.equals(owner)) close(s, false);
|
||||
}
|
||||
|
||||
public void closeAll() {
|
||||
for (var s : new ArrayList<>(sessions.values())) close(s, false);
|
||||
reports.clear();
|
||||
resolvers.clear();
|
||||
}
|
||||
|
||||
private void reputation(ShopSession s, OrderOutcome outcome) {
|
||||
data.reputation(
|
||||
s.shop.owner,
|
||||
Math.min(
|
||||
ShopConfig.MAX_REPUTATION.get(),
|
||||
Math.max(0, reputation(s.shop.owner) + s.rules.reputationDelta(outcome))));
|
||||
}
|
||||
|
||||
public void finish(ShopSession s, OrderOutcome outcome) {
|
||||
ShopOrder order = s.order;
|
||||
if (order == null) return;
|
||||
s.order = null;
|
||||
reputation(s, outcome);
|
||||
ServerLevel level = level(s.shop);
|
||||
if (outcome == OrderOutcome.SUCCESS) {
|
||||
s.shop.credit = Math.min(Long.MAX_VALUE - order.reward(), s.shop.credit) + order.reward();
|
||||
data.setDirty();
|
||||
withdraw(s.shop);
|
||||
}
|
||||
customers.finish(
|
||||
level,
|
||||
s,
|
||||
order.customer(),
|
||||
"cos.npc." + outcome.name().toLowerCase(Locale.ROOT),
|
||||
level.getGameTime());
|
||||
}
|
||||
|
||||
public void withdraw(Shop shop) {
|
||||
ServerPlayer owner = server.getPlayerList().getPlayer(shop.owner);
|
||||
if (owner == null) return;
|
||||
// Insert explicitly: Inventory.add discards overflow for creative players.
|
||||
for (int i = 0; i < 36 && shop.credit > 0; i++) {
|
||||
ItemStack existing = owner.getInventory().getItem(i);
|
||||
if (!existing.isEmpty()
|
||||
&& !ItemStack.isSameItemSameComponents(existing, new ItemStack(Items.EMERALD))) continue;
|
||||
int room = 64 - existing.getCount();
|
||||
int n = (int) Math.min(room, shop.credit);
|
||||
if (n <= 0) continue;
|
||||
if (existing.isEmpty()) owner.getInventory().setItem(i, new ItemStack(Items.EMERALD, n));
|
||||
else existing.grow(n);
|
||||
shop.credit -= n;
|
||||
data.setDirty();
|
||||
owner.getInventory().setChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void action(ServerPlayer player, ShopMenu menu, int action) {
|
||||
Shop shop = atCounter(player.serverLevel(), menu.pos);
|
||||
if (shop == null || !shop.owner.equals(player.getUUID())) return;
|
||||
ShopSession s = session(shop.id);
|
||||
String result = "";
|
||||
switch (action) {
|
||||
case 0 -> verify(shop);
|
||||
case 1 -> {
|
||||
menu.previewCatalogue = null;
|
||||
if (s == null) {
|
||||
var report = verify(shop);
|
||||
menu.preparing = report.valid();
|
||||
if (!report.valid()) result = "invalid_shop";
|
||||
}
|
||||
}
|
||||
case 2 -> {
|
||||
menu.previewCatalogue = null;
|
||||
if (menu.preparing) menu.selectedMode = ShopGameplayMode.STANDARD;
|
||||
}
|
||||
case 3 -> {
|
||||
menu.previewCatalogue = null;
|
||||
if (menu.preparing) menu.selectedMode = ShopGameplayMode.CHILL;
|
||||
}
|
||||
case 4 -> {
|
||||
if (menu.preparing) {
|
||||
result = open(player, shop, menu.selectedMode);
|
||||
if (result.equals("opened")) menu.preparing = false;
|
||||
}
|
||||
}
|
||||
case 5 -> {
|
||||
if (s != null) s.state = ShopSession.State.ARRIVALS_CLOSED;
|
||||
}
|
||||
case 6 -> {
|
||||
if (s != null) close(s, true);
|
||||
}
|
||||
case 7 -> {
|
||||
if (s != null) finish(s, OrderOutcome.REFUSED);
|
||||
}
|
||||
case 8 -> {
|
||||
if (s != null && s.order != null) {
|
||||
if (player.serverLevel().getGameTime() >= s.order.deadline())
|
||||
finish(s, OrderOutcome.TIMEOUT);
|
||||
else if (Delivery.consume(menu.inventory, s, s.order.products()))
|
||||
finish(s, OrderOutcome.SUCCESS);
|
||||
else result = "delivery_invalid";
|
||||
}
|
||||
}
|
||||
case 9 -> withdraw(shop);
|
||||
case 10 -> menu.preparing = false;
|
||||
default -> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!result.isEmpty()) {
|
||||
messages.put(shop.id, result);
|
||||
player.displayClientMessage(Component.translatable("cos.message." + result), true);
|
||||
}
|
||||
sync(player, menu);
|
||||
}
|
||||
|
||||
public void sync(ServerPlayer player, ShopMenu menu) {
|
||||
Shop shop = atCounter(player.serverLevel(), menu.pos);
|
||||
if (shop == null) return;
|
||||
CompoundTag tag = new CompoundTag();
|
||||
ShopSession s = session(shop.id);
|
||||
tag.putString("State", s == null ? "CLOSED" : s.state.name());
|
||||
tag.putString("Mode", s == null ? menu.selectedMode.name() : s.mode.name());
|
||||
tag.putBoolean("Preparing", menu.preparing);
|
||||
tag.putInt("Reputation", reputation(shop.owner));
|
||||
tag.putLong("Credit", shop.credit);
|
||||
tag.putInt("Rank", ShopConfig.rank(reputation(shop.owner)));
|
||||
tag.putString("Message", messages.getOrDefault(shop.id, ""));
|
||||
tag.putInt("Queue", s == null ? 0 : s.customers.size());
|
||||
ShopValidationResult report = reports.get(shop.id);
|
||||
if (report != null) {
|
||||
tag.putBoolean("Valid", report.valid());
|
||||
tag.putInt("Families", report.families().size());
|
||||
putStrings(tag, "Errors", report.errors());
|
||||
putStrings(tag, "Warnings", report.warnings());
|
||||
putStrings(tag, "FamilyNames", report.families().stream().sorted().toList());
|
||||
tag.putLongArray(
|
||||
"Incoming", report.incoming().stream().mapToLong(BlockPos::asLong).toArray());
|
||||
tag.putLongArray(
|
||||
"Outgoing", report.outgoing().stream().mapToLong(BlockPos::asLong).toArray());
|
||||
tag.putLongArray(
|
||||
"Stations", report.stations().keySet().stream().mapToLong(BlockPos::asLong).toArray());
|
||||
tag.putLongArray(
|
||||
"QueuePositions", report.queue().stream().mapToLong(BlockPos::asLong).toArray());
|
||||
}
|
||||
tag.putBoolean("Inspection", ShopConfig.INSPECTION.get());
|
||||
tag.putLong("Min", shop.min().asLong());
|
||||
tag.putLong("Max", shop.max().asLong());
|
||||
if (shop.entry != null) tag.putLong("Entry", shop.entry.asLong());
|
||||
if (shop.exit != null) tag.putLong("Exit", shop.exit.asLong());
|
||||
tag.putLongArray("Storages", shop.storages.stream().mapToLong(BlockPos::asLong).toArray());
|
||||
if (s != null) {
|
||||
tag.putBoolean("Quantities", s.quantities);
|
||||
tag.putDouble("Multiplier", s.rules.rewardMultiplier());
|
||||
putStrings(tag, "Catalogue", s.catalogue);
|
||||
if (s.order != null) {
|
||||
tag.putInt("Reward", s.order.reward());
|
||||
tag.putInt(
|
||||
"Patience",
|
||||
(int) Math.max(0, (s.order.deadline() - player.serverLevel().getGameTime()) / 20));
|
||||
ListTag products = new ListTag();
|
||||
s.order
|
||||
.products()
|
||||
.forEach(
|
||||
(id, n) -> {
|
||||
CompoundTag p = new CompoundTag();
|
||||
p.putString("Item", id);
|
||||
p.putInt("Count", n);
|
||||
products.add(p);
|
||||
});
|
||||
tag.put("Order", products);
|
||||
}
|
||||
} else if (menu.preparing && report != null && report.valid()) {
|
||||
var rules = ShopGameplayRules.forMode(menu.selectedMode, ShopConfig.rules());
|
||||
boolean quantities = rules.usesQuantities(reputation(shop.owner));
|
||||
tag.putBoolean("Quantities", quantities);
|
||||
tag.putDouble("Multiplier", rules.rewardMultiplier());
|
||||
var stock = ShopStock.collect(player.serverLevel(), shop, null, report);
|
||||
if (menu.previewCatalogue == null)
|
||||
menu.previewCatalogue =
|
||||
catalogue(resolver(player.serverLevel(), report), quantities ? stock : types(stock));
|
||||
putStrings(tag, "Catalogue", menu.previewCatalogue);
|
||||
}
|
||||
if (net.neoforged.neoforge.network.registration.NetworkRegistry.hasChannel(
|
||||
player.connection, ShopNetwork.View.TYPE.id()))
|
||||
PacketDistributor.sendToPlayer(player, new ShopNetwork.View(menu.containerId, tag));
|
||||
}
|
||||
|
||||
private static void putStrings(CompoundTag tag, String key, Collection<String> values) {
|
||||
ListTag list = new ListTag();
|
||||
values.stream().limit(2048).forEach(v -> list.add(StringTag.valueOf(v)));
|
||||
tag.put(key, list);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package fr.shinuwa.counterofshinuwa.core;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public record ShopOrder(
|
||||
UUID id, UUID customer, Map<String, Integer> products, int reward, long deadline) {
|
||||
public ShopOrder {
|
||||
products = Collections.unmodifiableMap(new LinkedHashMap<>(products));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package fr.shinuwa.counterofshinuwa.core;
|
||||
|
||||
import java.util.*;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.nbt.*;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
|
||||
public final class ShopSavedData extends SavedData {
|
||||
public final Map<UUID, Shop> shops = new LinkedHashMap<>();
|
||||
private final Map<UUID, Integer> reputations = new HashMap<>();
|
||||
|
||||
public static ShopSavedData get(MinecraftServer server) {
|
||||
return server
|
||||
.overworld()
|
||||
.getDataStorage()
|
||||
.computeIfAbsent(
|
||||
new Factory<>(ShopSavedData::new, ShopSavedData::load), "counter_of_shinuwa");
|
||||
}
|
||||
|
||||
public int reputation(UUID owner) {
|
||||
return reputations.getOrDefault(owner, 0);
|
||||
}
|
||||
|
||||
public void reputation(UUID owner, int value) {
|
||||
reputations.put(owner, Math.max(0, value));
|
||||
setDirty();
|
||||
}
|
||||
|
||||
private static ShopSavedData load(CompoundTag t, HolderLookup.Provider registries) {
|
||||
ShopSavedData data = new ShopSavedData();
|
||||
for (Tag tag : t.getList("Shops", Tag.TAG_COMPOUND)) {
|
||||
Shop shop = Shop.load((CompoundTag) tag);
|
||||
data.shops.put(shop.id, shop);
|
||||
}
|
||||
for (Tag tag : t.getList("Reputations", Tag.TAG_COMPOUND)) {
|
||||
CompoundTag r = (CompoundTag) tag;
|
||||
data.reputations.put(r.getUUID("Owner"), r.getInt("Value"));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompoundTag save(CompoundTag t, HolderLookup.Provider registries) {
|
||||
t.putInt("Version", 1);
|
||||
ListTag list = new ListTag();
|
||||
shops.values().forEach(s -> list.add(s.save()));
|
||||
t.put("Shops", list);
|
||||
ListTag reps = new ListTag();
|
||||
reputations.forEach(
|
||||
(owner, value) -> {
|
||||
CompoundTag r = new CompoundTag();
|
||||
r.putUUID("Owner", owner);
|
||||
r.putInt("Value", value);
|
||||
reps.add(r);
|
||||
});
|
||||
t.put("Reputations", reps);
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package fr.shinuwa.counterofshinuwa.core;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.customer.ShopCustomer;
|
||||
import fr.shinuwa.counterofshinuwa.gameplay.*;
|
||||
import fr.shinuwa.counterofshinuwa.recipe.RecipeResolver;
|
||||
import fr.shinuwa.counterofshinuwa.validation.ShopValidationResult;
|
||||
import java.util.*;
|
||||
|
||||
public final class ShopSession {
|
||||
public enum State {
|
||||
OPEN,
|
||||
ARRIVALS_CLOSED,
|
||||
FORCE_CLOSED,
|
||||
CLOSED
|
||||
}
|
||||
|
||||
public final UUID id = UUID.randomUUID();
|
||||
public final Shop shop;
|
||||
public final ShopGameplayMode mode;
|
||||
public final ShopGameplayRules rules;
|
||||
public final ShopValidationResult structure;
|
||||
public final RecipeResolver resolver;
|
||||
public List<String> catalogue;
|
||||
public final Map<String, Integer> materialSnapshot;
|
||||
public final boolean quantities;
|
||||
public final List<ShopCustomer> customers = new ArrayList<>();
|
||||
public State state = State.OPEN;
|
||||
public ShopOrder order;
|
||||
public long nextArrival;
|
||||
|
||||
public ShopSession(
|
||||
Shop shop,
|
||||
ShopGameplayMode mode,
|
||||
ShopGameplayRules rules,
|
||||
ShopValidationResult structure,
|
||||
RecipeResolver resolver,
|
||||
List<String> catalogue,
|
||||
Map<String, Integer> snapshot,
|
||||
boolean quantities,
|
||||
long now) {
|
||||
this.shop = shop;
|
||||
this.mode = mode;
|
||||
this.rules = rules;
|
||||
this.structure = structure;
|
||||
this.resolver = resolver;
|
||||
this.catalogue = List.copyOf(catalogue);
|
||||
this.materialSnapshot = Map.copyOf(snapshot);
|
||||
this.quantities = quantities;
|
||||
nextArrival = now + 40;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package fr.shinuwa.counterofshinuwa.customer;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.core.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
|
||||
public final class CustomerManager {
|
||||
private final NpcAdapter adapter;
|
||||
|
||||
public CustomerManager(NpcAdapter adapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
public void tick(ServerLevel level, ShopSession s, ShopManager manager, long now) {
|
||||
int waiting =
|
||||
(int) s.customers.stream().filter(c -> c.state != ShopCustomer.State.LEAVING).count();
|
||||
int limit =
|
||||
Math.min(s.structure.queue().size(), s.rules.maxQueue(manager.reputation(s.shop.owner)));
|
||||
if (s.state == ShopSession.State.OPEN && waiting < limit && now >= s.nextArrival) {
|
||||
adapter
|
||||
.spawn(level, s, s.shop.entry)
|
||||
.ifPresent(npc -> s.customers.add(new ShopCustomer(npc.getUUID(), now)));
|
||||
s.nextArrival = now + s.rules.spawnInterval(manager.reputation(s.shop.owner));
|
||||
}
|
||||
int rank = 0;
|
||||
for (ShopCustomer c : new ArrayList<>(s.customers)) {
|
||||
if (!(level.getEntity(c.npc) instanceof Mob mob)) {
|
||||
removeMissing(s, c);
|
||||
continue;
|
||||
}
|
||||
if (c.state == ShopCustomer.State.LEAVING) {
|
||||
if (c.waypoint >= s.structure.outgoing().size()) {
|
||||
if (reached(mob, s.shop.exit) || now - c.lastProgress > 600) {
|
||||
adapter.remove(mob);
|
||||
s.customers.remove(c);
|
||||
} else move(mob, c, s.shop.exit, now);
|
||||
} else if (reached(mob, s.structure.outgoing().get(c.waypoint))) {
|
||||
c.waypoint++;
|
||||
c.lastProgress = now;
|
||||
} else move(mob, c, s.structure.outgoing().get(c.waypoint), now);
|
||||
if (now - c.lastProgress > 600) {
|
||||
adapter.remove(mob);
|
||||
s.customers.remove(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
int queueIndex = rank++;
|
||||
BlockPos target =
|
||||
s.structure.queue().get(Math.min(queueIndex, s.structure.queue().size() - 1));
|
||||
int stop = s.structure.incoming().indexOf(target);
|
||||
if (c.waypoint <= stop) {
|
||||
BlockPos waypoint = s.structure.incoming().get(c.waypoint);
|
||||
if (reached(mob, waypoint)) {
|
||||
c.waypoint++;
|
||||
c.lastProgress = now;
|
||||
} else move(mob, c, waypoint, now);
|
||||
if (now - c.lastProgress > 600) {
|
||||
adapter.remove(mob);
|
||||
removeMissing(s, c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
c.lastProgress = now;
|
||||
if (queueIndex == 0 && s.order == null) {
|
||||
var order = manager.generateOrder(level, s, c.id, now);
|
||||
if (order.isPresent()) {
|
||||
s.order = order.get();
|
||||
c.state = ShopCustomer.State.ORDER_ACTIVE;
|
||||
adapter.say(mob, "cos.npc.order");
|
||||
} else leave(level, s, c, "cos.npc.no_stock", now);
|
||||
} else if (c.state != ShopCustomer.State.ORDER_ACTIVE)
|
||||
c.state = ShopCustomer.State.WAITING_IN_QUEUE;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean reached(Mob mob, BlockPos p) {
|
||||
return mob.distanceToSqr(p.getX() + .5, p.getY() + .125, p.getZ() + .5) < .6;
|
||||
}
|
||||
|
||||
private void move(Mob mob, ShopCustomer c, BlockPos p, long now) {
|
||||
if (!p.equals(c.target) || now - c.lastMove >= 40) {
|
||||
adapter.move(mob, p);
|
||||
c.target = p;
|
||||
c.lastMove = now;
|
||||
}
|
||||
}
|
||||
|
||||
private void removeMissing(ShopSession s, ShopCustomer c) {
|
||||
if (s.order != null && s.order.customer().equals(c.id)) s.order = null;
|
||||
s.customers.remove(c);
|
||||
}
|
||||
|
||||
public void finish(ServerLevel level, ShopSession s, UUID customer, String key, long now) {
|
||||
s.customers.stream()
|
||||
.filter(c -> c.id.equals(customer))
|
||||
.findFirst()
|
||||
.ifPresent(c -> leave(level, s, c, key, now));
|
||||
}
|
||||
|
||||
private void leave(ServerLevel level, ShopSession s, ShopCustomer c, String key, long now) {
|
||||
c.state = ShopCustomer.State.LEAVING;
|
||||
c.waypoint = 0;
|
||||
c.lastProgress = now;
|
||||
c.target = null;
|
||||
if (level.getEntity(c.npc) instanceof Mob mob) adapter.say(mob, key);
|
||||
}
|
||||
|
||||
public void clear(ServerLevel level, ShopSession s) {
|
||||
for (var c : s.customers) if (level.getEntity(c.npc) instanceof Mob mob) adapter.remove(mob);
|
||||
s.customers.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package fr.shinuwa.counterofshinuwa.customer;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.core.ShopSession;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
|
||||
public interface NpcAdapter {
|
||||
Optional<Mob> spawn(ServerLevel level, ShopSession session, BlockPos position);
|
||||
|
||||
void move(Mob npc, BlockPos position);
|
||||
|
||||
void say(Mob npc, String translationKey);
|
||||
|
||||
void remove(Mob npc);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package fr.shinuwa.counterofshinuwa.customer;
|
||||
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
|
||||
public final class ShopCustomer {
|
||||
public enum State {
|
||||
WALKING_TO_QUEUE,
|
||||
WAITING_IN_QUEUE,
|
||||
ORDER_ACTIVE,
|
||||
LEAVING,
|
||||
DESPAWNED
|
||||
}
|
||||
|
||||
public final UUID id, npc;
|
||||
public State state = State.WALKING_TO_QUEUE;
|
||||
public int waypoint;
|
||||
public BlockPos target;
|
||||
public long lastProgress, lastMove;
|
||||
|
||||
public ShopCustomer(UUID npc, long now) {
|
||||
id = UUID.randomUUID();
|
||||
this.npc = npc;
|
||||
lastProgress = now;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package fr.shinuwa.counterofshinuwa.gameplay;
|
||||
|
||||
public record ChillGameplayRules(ShopGameplayRules.RuleSettings settings)
|
||||
implements ShopGameplayRules {
|
||||
public double rewardMultiplier() {
|
||||
return settings.chillReward();
|
||||
}
|
||||
|
||||
public int reputationDelta(OrderOutcome outcome) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public boolean usesQuantities(int reputation) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public int maxQueue(int reputation) {
|
||||
return settings.chillQueue();
|
||||
}
|
||||
|
||||
public int spawnInterval(int reputation) {
|
||||
return settings.chillInterval();
|
||||
}
|
||||
|
||||
public int maxQuantity(int reputation) {
|
||||
return Math.min(4, settings.maxQuantity());
|
||||
}
|
||||
|
||||
public int patienceTicks(int productionTicks) {
|
||||
return (int) Math.min(144000, (2400L + productionTicks * 2L) * settings.chillPatience());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package fr.shinuwa.counterofshinuwa.gameplay;
|
||||
|
||||
public enum OrderOutcome {
|
||||
SUCCESS,
|
||||
REFUSED,
|
||||
TIMEOUT,
|
||||
FORCE_CLOSE
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package fr.shinuwa.counterofshinuwa.gameplay;
|
||||
|
||||
public enum ShopGameplayMode {
|
||||
STANDARD,
|
||||
CHILL
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package fr.shinuwa.counterofshinuwa.gameplay;
|
||||
|
||||
public interface ShopGameplayRules {
|
||||
double rewardMultiplier();
|
||||
|
||||
int reputationDelta(OrderOutcome outcome);
|
||||
|
||||
boolean usesQuantities(int reputation);
|
||||
|
||||
int maxQueue(int reputation);
|
||||
|
||||
int spawnInterval(int reputation);
|
||||
|
||||
int maxQuantity(int reputation);
|
||||
|
||||
int patienceTicks(int productionTicks);
|
||||
|
||||
static ShopGameplayRules forMode(ShopGameplayMode mode, RuleSettings settings) {
|
||||
return mode == ShopGameplayMode.CHILL
|
||||
? new ChillGameplayRules(settings)
|
||||
: new StandardGameplayRules(settings);
|
||||
}
|
||||
|
||||
record RuleSettings(
|
||||
int advancedThreshold,
|
||||
double chillReward,
|
||||
int success,
|
||||
int refused,
|
||||
int timeout,
|
||||
int forceClose,
|
||||
int standardInterval,
|
||||
int chillInterval,
|
||||
int standardQueue,
|
||||
int chillQueue,
|
||||
int maxQuantity,
|
||||
double chillPatience) {
|
||||
public static RuleSettings defaults() {
|
||||
return new RuleSettings(450, .6, 2, -2, -5, -10, 900, 1800, 6, 3, 16, 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package fr.shinuwa.counterofshinuwa.gameplay;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.ToIntFunction;
|
||||
|
||||
public final class ShopPricing {
|
||||
private ShopPricing() {}
|
||||
|
||||
/**
|
||||
* Apply the mode to the whole price, retaining fractional complexity until the final rounding.
|
||||
*/
|
||||
public static int reward(
|
||||
Map<String, Integer> products,
|
||||
ToIntFunction<String> unitPrice,
|
||||
int steps,
|
||||
double complexity,
|
||||
double multiplier) {
|
||||
double total = steps * complexity;
|
||||
for (var product : products.entrySet())
|
||||
total += (long) unitPrice.applyAsInt(product.getKey()) * product.getValue();
|
||||
return (int) Math.min(Integer.MAX_VALUE, Math.max(0, Math.floor(total * multiplier)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package fr.shinuwa.counterofshinuwa.gameplay;
|
||||
|
||||
public record StandardGameplayRules(ShopGameplayRules.RuleSettings settings)
|
||||
implements ShopGameplayRules {
|
||||
public double rewardMultiplier() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
public int reputationDelta(OrderOutcome outcome) {
|
||||
return switch (outcome) {
|
||||
case SUCCESS -> settings.success();
|
||||
case REFUSED -> settings.refused();
|
||||
case TIMEOUT -> settings.timeout();
|
||||
case FORCE_CLOSE -> settings.forceClose();
|
||||
};
|
||||
}
|
||||
|
||||
public boolean usesQuantities(int reputation) {
|
||||
return reputation < settings.advancedThreshold();
|
||||
}
|
||||
|
||||
public int maxQueue(int reputation) {
|
||||
return Math.min(settings.standardQueue(), 2 + reputation / 200);
|
||||
}
|
||||
|
||||
public int spawnInterval(int reputation) {
|
||||
return Math.max(200, settings.standardInterval() - reputation / 2);
|
||||
}
|
||||
|
||||
public int maxQuantity(int reputation) {
|
||||
return Math.min(settings.maxQuantity(), 4 + reputation / 75);
|
||||
}
|
||||
|
||||
public int patienceTicks(int productionTicks) {
|
||||
return 2400 + (int) Math.min(72000L, productionTicks * 2L);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package fr.shinuwa.counterofshinuwa.integration.easynpc;
|
||||
|
||||
import de.markusbordihn.easynpc.api.action.EasyNPCActionHandler;
|
||||
import de.markusbordihn.easynpc.api.handler.EasyNPCEntityHandler;
|
||||
import de.markusbordihn.easynpc.entity.easynpc.EasyNPC;
|
||||
import fr.shinuwa.counterofshinuwa.*;
|
||||
import fr.shinuwa.counterofshinuwa.core.ShopSession;
|
||||
import fr.shinuwa.counterofshinuwa.customer.NpcAdapter;
|
||||
import java.util.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.*;
|
||||
|
||||
public final class EasyNpcAdapter implements NpcAdapter {
|
||||
private static final Map<Mob, BlockPos> TARGETS = new WeakHashMap<>();
|
||||
private static final ResourceLocation TRAVEL = CounterOfShinuwa.id("customer_travel");
|
||||
private final java.util.function.Supplier<List<? extends String>> appearances;
|
||||
|
||||
public EasyNpcAdapter() {
|
||||
appearances = () -> ShopConfig.APPEARANCES.get();
|
||||
}
|
||||
|
||||
public EasyNpcAdapter(List<String> appearances) {
|
||||
this.appearances = () -> List.copyOf(appearances);
|
||||
}
|
||||
|
||||
public static void register() {
|
||||
// Villager-based NPCs gate travel on objective metadata. Actions alone do not enable it.
|
||||
// This objective grants travel capability and follows only destinations chosen by the shop.
|
||||
de.markusbordihn.easynpc.api.objective.EasyNPCObjectiveRegistry.register(
|
||||
TRAVEL,
|
||||
new de.markusbordihn.easynpc.data.objective.ObjectiveGoalFactory() {
|
||||
@Override
|
||||
public boolean hasTravelObjective() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public net.minecraft.world.entity.ai.goal.Goal createGoal(
|
||||
de.markusbordihn.easynpc.data.objective.ObjectiveDataEntry entry, EasyNPC<?> npc) {
|
||||
return new net.minecraft.world.entity.ai.goal.Goal() {
|
||||
private final Mob mob = npc.getMob();
|
||||
private int ticks;
|
||||
|
||||
{
|
||||
setFlags(java.util.EnumSet.of(Flag.MOVE));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canUse() {
|
||||
return TARGETS.containsKey(mob);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresUpdateEveryTick() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
ticks = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
if (ticks++ % 10 != 0) return;
|
||||
BlockPos target = TARGETS.get(mob);
|
||||
if (target == null) return;
|
||||
if (mob.distanceToSqr(target.getX() + .5, target.getY() + .125, target.getZ() + .5)
|
||||
< .12) {
|
||||
mob.getNavigation().stop();
|
||||
return;
|
||||
}
|
||||
var path = mob.getNavigation().createPath(target, 0);
|
||||
if (path != null) mob.getNavigation().moveTo(path, .6);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
mob.getNavigation().stop();
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Optional<Mob> spawn(ServerLevel level, ShopSession session, BlockPos position) {
|
||||
List<String> configured = new ArrayList<>(appearances.get());
|
||||
Collections.shuffle(configured);
|
||||
for (String name : configured) {
|
||||
ResourceLocation id = ResourceLocation.tryParse(name);
|
||||
if (id == null || !BuiltInRegistries.ENTITY_TYPE.containsKey(id)) continue;
|
||||
Entity entity = BuiltInRegistries.ENTITY_TYPE.get(id).create(level);
|
||||
if (!(entity instanceof Mob mob) || !(entity instanceof EasyNPC<?> npc)) continue;
|
||||
mob.moveTo(position.getX() + .5, position.getY() + .125, position.getZ() + .5, 0, 0);
|
||||
mob.setPersistenceRequired();
|
||||
mob.setInvulnerable(true);
|
||||
mob.setCanPickUpLoot(false);
|
||||
mob.setCustomName(Component.translatable("cos.customer"));
|
||||
mob.setCustomNameVisible(true);
|
||||
mob.getPersistentData().putUUID("cos_session", session.id);
|
||||
mob.getPersistentData().putUUID("cos_shop", session.shop.id);
|
||||
npc.getEasyNPCOwnerData().setNPCOwnerUUID(session.shop.owner);
|
||||
npc.getEasyNPCObjectiveData()
|
||||
.addOrUpdateCustomObjective(
|
||||
new de.markusbordihn.easynpc.data.objective.ObjectiveDataEntry(TRAVEL));
|
||||
if (!level.noCollision(mob, mob.getBoundingBox())) continue;
|
||||
if (level.addFreshEntity(mob)) {
|
||||
say(mob, "cos.npc.hello");
|
||||
return Optional.of(mob);
|
||||
}
|
||||
}
|
||||
CounterOfShinuwa.LOGGER.warn("No usable Easy NPC customer type at {}", position);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public void move(Mob mob, BlockPos position) {
|
||||
// Easy NPC 7.12.1's move action uses path accuracy 1, stopping on the previous carpet.
|
||||
// Exact waypoints need accuracy 0. Travel remains enabled through the public objective API.
|
||||
TARGETS.put(mob, position.immutable());
|
||||
}
|
||||
|
||||
public void say(Mob mob, String key) {
|
||||
if (mob instanceof EasyNPC<?> npc) EasyNPCActionHandler.showSpeechBubble(npc, key);
|
||||
}
|
||||
|
||||
public void remove(Mob mob) {
|
||||
TARGETS.remove(mob);
|
||||
if (mob instanceof EasyNPC<?> npc) EasyNPCEntityHandler.delete(npc);
|
||||
if (!mob.isRemoved()) mob.discard();
|
||||
}
|
||||
}
|
||||
112
src/main/java/fr/shinuwa/counterofshinuwa/menu/ShopMenu.java
Normal file
112
src/main/java/fr/shinuwa/counterofshinuwa/menu/ShopMenu.java
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package fr.shinuwa.counterofshinuwa.menu;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.*;
|
||||
import fr.shinuwa.counterofshinuwa.block.ShopInventoryBlockEntity;
|
||||
import fr.shinuwa.counterofshinuwa.core.ShopManager;
|
||||
import fr.shinuwa.counterofshinuwa.gameplay.ShopGameplayMode;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.*;
|
||||
import net.minecraft.world.entity.player.*;
|
||||
import net.minecraft.world.inventory.*;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
public final class ShopMenu extends AbstractContainerMenu {
|
||||
public final BlockPos pos;
|
||||
public final boolean storage;
|
||||
public final UUID owner;
|
||||
public final Container inventory;
|
||||
public boolean preparing;
|
||||
public ShopGameplayMode selectedMode = ShopGameplayMode.STANDARD;
|
||||
public CompoundTag view = new CompoundTag();
|
||||
public java.util.List<String> previewCatalogue;
|
||||
private final Player viewer;
|
||||
private int ticks;
|
||||
|
||||
public ShopMenu(int id, Inventory inv, BlockPos pos, boolean storage, UUID owner) {
|
||||
this(id, inv, new SimpleContainer(storage ? 27 : 5), pos, storage, owner);
|
||||
}
|
||||
|
||||
public ShopMenu(int id, Inventory inv, ShopInventoryBlockEntity be, UUID owner) {
|
||||
this(id, inv, be, be.getBlockPos(), be.storage(), owner);
|
||||
}
|
||||
|
||||
private ShopMenu(
|
||||
int id, Inventory inv, Container container, BlockPos pos, boolean storage, UUID owner) {
|
||||
super(CounterOfShinuwa.SHOP_MENU.get(), id);
|
||||
this.pos = pos;
|
||||
this.storage = storage;
|
||||
this.owner = owner;
|
||||
inventory = container;
|
||||
viewer = inv.player;
|
||||
int count = storage ? 27 : 5;
|
||||
for (int i = 0; i < count; i++) {
|
||||
int x = storage ? 8 + (i % 9) * 18 : 8 + i * 18, y = storage ? 24 + (i / 9) * 18 : 152;
|
||||
addSlot(
|
||||
new Slot(container, i, x, y) {
|
||||
@Override
|
||||
public boolean mayPlace(ItemStack stack) {
|
||||
return (storage || owner.equals(viewer.getUUID()))
|
||||
&& (!storage || ShopConfig.material(stack));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mayPickup(Player player) {
|
||||
return owner.equals(player.getUUID());
|
||||
}
|
||||
});
|
||||
}
|
||||
int base = storage ? 92 : 184;
|
||||
for (int row = 0; row < 3; row++)
|
||||
for (int col = 0; col < 9; col++)
|
||||
addSlot(new Slot(inv, col + row * 9 + 9, 8 + col * 18, base + row * 18));
|
||||
for (int col = 0; col < 9; col++) addSlot(new Slot(inv, col, 8 + col * 18, base + 58));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean stillValid(Player p) {
|
||||
return p.distanceToSqr(pos.getX() + .5, pos.getY() + .5, pos.getZ() + .5) <= 64
|
||||
&& p.level()
|
||||
.getBlockState(pos)
|
||||
.is(storage ? CounterOfShinuwa.STORAGE.get() : CounterOfShinuwa.COUNTER.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ItemStack quickMoveStack(Player player, int index) {
|
||||
if (index < 0 || index >= slots.size()) return ItemStack.EMPTY;
|
||||
Slot slot = slots.get(index);
|
||||
if (!slot.hasItem() || !slot.mayPickup(player)) return ItemStack.EMPTY;
|
||||
ItemStack stack = slot.getItem(), copy = stack.copy();
|
||||
int count = storage ? 27 : 5;
|
||||
if (index < count) {
|
||||
if (!moveItemStackTo(stack, count, slots.size(), true)) return ItemStack.EMPTY;
|
||||
} else {
|
||||
if (!storage && !owner.equals(player.getUUID())) return ItemStack.EMPTY;
|
||||
if (storage && !ShopConfig.material(stack)) return ItemStack.EMPTY;
|
||||
if (!moveItemStackTo(stack, 0, count, false)) return ItemStack.EMPTY;
|
||||
}
|
||||
if (stack.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
|
||||
else slot.setChanged();
|
||||
slot.onTake(player, stack);
|
||||
return copy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean clickMenuButton(Player player, int action) {
|
||||
if (storage
|
||||
|| !(player instanceof ServerPlayer sp)
|
||||
|| !owner.equals(player.getUUID())
|
||||
|| !stillValid(player)) return false;
|
||||
ShopManager.get(sp.server).action(sp, this, action);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void broadcastChanges() {
|
||||
super.broadcastChanges();
|
||||
if (!storage && viewer instanceof ServerPlayer sp && ticks++ % 20 == 0)
|
||||
ShopManager.get(sp.server).sync(sp, this);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package fr.shinuwa.counterofshinuwa.mixin;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.production.MenuAccess;
|
||||
import net.minecraft.world.inventory.*;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
@Mixin(CraftingMenu.class)
|
||||
public interface CraftingMenuAccess extends MenuAccess {
|
||||
@Accessor("access")
|
||||
ContainerLevelAccess cosAccess();
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package fr.shinuwa.counterofshinuwa.mixin;
|
||||
|
||||
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
|
||||
import fr.shinuwa.counterofshinuwa.compat.vanilla.VanillaProductionHooks;
|
||||
import net.minecraft.core.NonNullList;
|
||||
import net.minecraft.world.inventory.ResultSlot;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
|
||||
@Mixin(ResultSlot.class)
|
||||
public abstract class CraftingRemainderMixin {
|
||||
@ModifyExpressionValue(
|
||||
method = "onTake",
|
||||
at =
|
||||
@At(
|
||||
value = "INVOKE",
|
||||
target =
|
||||
"Lnet/minecraft/world/item/crafting/RecipeManager;getRemainingItemsFor(Lnet/minecraft/world/item/crafting/RecipeType;Lnet/minecraft/world/item/crafting/RecipeInput;Lnet/minecraft/world/level/Level;)Lnet/minecraft/core/NonNullList;"))
|
||||
private NonNullList<ItemStack> cosRemainders(NonNullList<ItemStack> items) {
|
||||
return VanillaProductionHooks.remainders(items);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package fr.shinuwa.counterofshinuwa.mixin;
|
||||
|
||||
import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
@Mixin(AbstractFurnaceBlockEntity.class)
|
||||
public interface FurnaceAccess {
|
||||
@Accessor("cookingProgress")
|
||||
void cosResetProgress(int ticks);
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package fr.shinuwa.counterofshinuwa.mixin;
|
||||
|
||||
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
|
||||
import fr.shinuwa.counterofshinuwa.compat.vanilla.VanillaProductionHooks;
|
||||
import net.minecraft.core.*;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.crafting.RecipeHolder;
|
||||
import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.*;
|
||||
|
||||
@Mixin(AbstractFurnaceBlockEntity.class)
|
||||
public abstract class FurnaceProductionMixin {
|
||||
@ModifyExpressionValue(
|
||||
method = {"canBurn", "burn"},
|
||||
at =
|
||||
@At(
|
||||
value = "INVOKE",
|
||||
target =
|
||||
"Lnet/minecraft/world/item/crafting/AbstractCookingRecipe;assemble(Lnet/minecraft/world/item/crafting/SingleRecipeInput;Lnet/minecraft/core/HolderLookup$Provider;)Lnet/minecraft/world/item/ItemStack;"))
|
||||
private static ItemStack cosOutput(
|
||||
ItemStack result,
|
||||
RegistryAccess registry,
|
||||
RecipeHolder<?> recipe,
|
||||
NonNullList<ItemStack> items,
|
||||
int max,
|
||||
AbstractFurnaceBlockEntity furnace) {
|
||||
return VanillaProductionHooks.furnaceResult(furnace, result);
|
||||
}
|
||||
|
||||
@Inject(method = "burn", at = @At("RETURN"))
|
||||
private static void cosFinished(
|
||||
RegistryAccess registry,
|
||||
RecipeHolder<?> recipe,
|
||||
NonNullList<ItemStack> items,
|
||||
int max,
|
||||
AbstractFurnaceBlockEntity furnace,
|
||||
CallbackInfoReturnable<Boolean> cir) {
|
||||
VanillaProductionHooks.cooked(furnace, cir.getReturnValue());
|
||||
}
|
||||
|
||||
@Inject(method = "setItem", at = @At("HEAD"))
|
||||
private void cosInput(int slot, ItemStack stack, CallbackInfo ci) {
|
||||
if (slot != 2)
|
||||
VanillaProductionHooks.inventoryChanged((AbstractFurnaceBlockEntity) (Object) this);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package fr.shinuwa.counterofshinuwa.mixin;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.compat.vanilla.VanillaProductionHooks;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.entity.HopperBlockEntity;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(HopperBlockEntity.class)
|
||||
public abstract class HopperMixin {
|
||||
@Inject(method = "tryMoveInItem", at = @At("HEAD"))
|
||||
private static void cosAutomation(
|
||||
Container source,
|
||||
Container destination,
|
||||
ItemStack stack,
|
||||
int slot,
|
||||
Direction side,
|
||||
CallbackInfoReturnable<ItemStack> cir) {
|
||||
if (slot != 2) VanillaProductionHooks.automation(destination);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package fr.shinuwa.counterofshinuwa.mixin;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.production.MenuAccess;
|
||||
import net.minecraft.world.inventory.*;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
@Mixin(ItemCombinerMenu.class)
|
||||
public interface ItemCombinerMenuAccess extends MenuAccess {
|
||||
@Accessor("access")
|
||||
ContainerLevelAccess cosAccess();
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package fr.shinuwa.counterofshinuwa.mixin;
|
||||
|
||||
import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
|
||||
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
|
||||
import fr.shinuwa.counterofshinuwa.compat.vanilla.VanillaProductionHooks;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.inventory.*;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
|
||||
@Mixin(AbstractContainerMenu.class)
|
||||
public abstract class MenuExtractionMixin {
|
||||
@WrapMethod(method = "clicked")
|
||||
private void cosTransaction(
|
||||
int slot, int button, ClickType type, Player player, Operation<Void> original) {
|
||||
VanillaProductionHooks.begin((AbstractContainerMenu) (Object) this, player);
|
||||
try {
|
||||
original.call(slot, button, type, player);
|
||||
} finally {
|
||||
VanillaProductionHooks.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package fr.shinuwa.counterofshinuwa.mixin;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.compat.vanilla.VanillaProductionHooks;
|
||||
import net.minecraft.world.inventory.ResultContainer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(ResultContainer.class)
|
||||
public abstract class ResultContainerMixin {
|
||||
@Inject(method = "getItem", at = @At("RETURN"))
|
||||
private void cosExtract(int index, CallbackInfoReturnable<ItemStack> cir) {
|
||||
VanillaProductionHooks.decorate((ResultContainer) (Object) this, cir.getReturnValue());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package fr.shinuwa.counterofshinuwa.mixin;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.production.MenuAccess;
|
||||
import net.minecraft.world.inventory.*;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
@Mixin(StonecutterMenu.class)
|
||||
public interface StonecutterMenuAccess extends MenuAccess {
|
||||
@Accessor("access")
|
||||
ContainerLevelAccess cosAccess();
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package fr.shinuwa.counterofshinuwa.network;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.CounterOfShinuwa;
|
||||
import fr.shinuwa.counterofshinuwa.menu.ShopMenu;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
|
||||
|
||||
public final class ShopNetwork {
|
||||
public record View(int menuId, CompoundTag data) implements CustomPacketPayload {
|
||||
public static final Type<View> TYPE = new Type<>(CounterOfShinuwa.id("shop_view"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, View> CODEC =
|
||||
StreamCodec.of(
|
||||
(buf, p) -> {
|
||||
buf.writeVarInt(p.menuId);
|
||||
buf.writeNbt(p.data);
|
||||
},
|
||||
buf -> new View(buf.readVarInt(), buf.readNbt()));
|
||||
|
||||
@Override
|
||||
public Type<? extends CustomPacketPayload> type() {
|
||||
return TYPE;
|
||||
}
|
||||
}
|
||||
|
||||
public static void register(RegisterPayloadHandlersEvent event) {
|
||||
event
|
||||
.registrar("1")
|
||||
.playToClient(
|
||||
View.TYPE,
|
||||
View.CODEC,
|
||||
(payload, context) -> {
|
||||
if (context.player().containerMenu instanceof ShopMenu menu
|
||||
&& menu.containerId == payload.menuId()) menu.view = payload.data();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package fr.shinuwa.counterofshinuwa.path;
|
||||
|
||||
import java.util.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
|
||||
/** A carpet path is one unbranched chain. Endpoints must connect to the selected markers. */
|
||||
public final class ShopPathManager {
|
||||
public static List<BlockPos> chain(Set<BlockPos> tiles, BlockPos start, BlockPos end) {
|
||||
if (tiles.isEmpty() || start == null || end == null) return List.of();
|
||||
List<BlockPos> first = tiles.stream().filter(p -> adjacent(p, start)).toList();
|
||||
if (first.size() != 1) return List.of();
|
||||
List<BlockPos> path = new ArrayList<>();
|
||||
Set<BlockPos> visited = new HashSet<>();
|
||||
BlockPos current = first.getFirst();
|
||||
while (current != null) {
|
||||
if (!visited.add(current)) return List.of();
|
||||
path.add(current);
|
||||
List<BlockPos> next =
|
||||
neighbors(current).stream()
|
||||
.filter(tiles::contains)
|
||||
.filter(p -> !visited.contains(p))
|
||||
.toList();
|
||||
long degree = neighbors(current).stream().filter(tiles::contains).count();
|
||||
if (degree > 2 || next.size() > 1) return List.of();
|
||||
current = next.isEmpty() ? null : next.getFirst();
|
||||
}
|
||||
return visited.size() == tiles.size() && adjacent(path.getLast(), end)
|
||||
? List.copyOf(path)
|
||||
: List.of();
|
||||
}
|
||||
|
||||
private static boolean adjacent(BlockPos a, BlockPos b) {
|
||||
return Math.abs(a.getX() - b.getX()) + Math.abs(a.getZ() - b.getZ()) == 1
|
||||
&& Math.abs(a.getY() - b.getY()) <= 1;
|
||||
}
|
||||
|
||||
private static List<BlockPos> neighbors(BlockPos pos) {
|
||||
List<BlockPos> result = new ArrayList<>();
|
||||
for (Direction d : Direction.Plane.HORIZONTAL)
|
||||
for (int y = -1; y <= 1; y++) result.add(pos.relative(d).offset(0, y, 0));
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<BlockPos> queue(List<BlockPos> incoming) {
|
||||
List<BlockPos> positions = new ArrayList<>();
|
||||
for (int i = incoming.size() - 1; i >= 0; i -= 2) positions.add(incoming.get(i));
|
||||
return positions;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package fr.shinuwa.counterofshinuwa.production;
|
||||
|
||||
import net.minecraft.world.inventory.ContainerLevelAccess;
|
||||
|
||||
public interface MenuAccess {
|
||||
ContainerLevelAccess cosAccess();
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package fr.shinuwa.counterofshinuwa.production;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.core.ShopSession;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
|
||||
public record ProductionContext(
|
||||
ShopSession session, UUID producer, BlockPos station, String handler, String family) {}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package fr.shinuwa.counterofshinuwa.production;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.core.ShopSession;
|
||||
import java.util.List;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Integration hooks supply actual consumed inputs; this validates their session context. */
|
||||
public interface ProductionHandler {
|
||||
String id();
|
||||
|
||||
boolean accepts(ShopSession session, List<ItemStack> inputs);
|
||||
|
||||
default void reset() {}
|
||||
|
||||
default List<ItemStack> stock(
|
||||
ShopSession session, net.minecraft.world.level.block.entity.BlockEntity station) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package fr.shinuwa.counterofshinuwa.production;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import fr.shinuwa.counterofshinuwa.core.ShopSession;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.UUIDUtil;
|
||||
|
||||
public record ShopProductionData(
|
||||
UUID shopId, UUID sessionId, UUID crafterId, String handler, String family) {
|
||||
public static final Codec<ShopProductionData> CODEC =
|
||||
RecordCodecBuilder.create(
|
||||
i ->
|
||||
i.group(
|
||||
UUIDUtil.CODEC.fieldOf("shop").forGetter(ShopProductionData::shopId),
|
||||
UUIDUtil.CODEC.fieldOf("session").forGetter(ShopProductionData::sessionId),
|
||||
UUIDUtil.CODEC.fieldOf("crafter").forGetter(ShopProductionData::crafterId),
|
||||
Codec.STRING.fieldOf("handler").forGetter(ShopProductionData::handler),
|
||||
Codec.STRING.fieldOf("family").forGetter(ShopProductionData::family))
|
||||
.apply(i, ShopProductionData::new));
|
||||
|
||||
public boolean valid(ShopSession session) {
|
||||
return session != null
|
||||
&& shopId.equals(session.shop.id)
|
||||
&& sessionId.equals(session.id)
|
||||
&& crafterId.equals(session.shop.owner);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package fr.shinuwa.counterofshinuwa.production;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.*;
|
||||
import fr.shinuwa.counterofshinuwa.core.*;
|
||||
import fr.shinuwa.counterofshinuwa.station.ProductionRegistry;
|
||||
import java.util.List;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/**
|
||||
* Shared provenance contract. Integrations call produced only inside a real production operation.
|
||||
*/
|
||||
public final class ShopProductionTracker {
|
||||
public static boolean valid(ShopSession s, ItemStack stack) {
|
||||
var data = stack.get(CounterOfShinuwa.PRODUCTION.get());
|
||||
return data != null && data.valid(s);
|
||||
}
|
||||
|
||||
public static boolean ingredient(ShopSession s, ItemStack stack) {
|
||||
return !stack.isEmpty() && (ShopConfig.material(stack) || valid(s, stack));
|
||||
}
|
||||
|
||||
public static boolean produced(
|
||||
ProductionContext context, List<ItemStack> inputs, ItemStack output) {
|
||||
output.remove(CounterOfShinuwa.PRODUCTION.get());
|
||||
ShopSession s = context.session();
|
||||
if (output.isEmpty()
|
||||
|| s == null
|
||||
|| !context.producer().equals(s.shop.owner)
|
||||
|| !s.shop.contains(context.station())
|
||||
|| !s.structure.families().contains(context.family())
|
||||
|| s.state == ShopSession.State.CLOSED
|
||||
|| s.state == ShopSession.State.FORCE_CLOSED) return false;
|
||||
var handler = ProductionRegistry.INSTANCE.handler(context.handler());
|
||||
if (handler == null || !handler.accepts(s, inputs)) return false;
|
||||
output.set(
|
||||
CounterOfShinuwa.PRODUCTION.get(),
|
||||
new ShopProductionData(
|
||||
s.shop.id, s.id, context.producer(), context.handler(), context.family()));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package fr.shinuwa.counterofshinuwa.production;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.ShopConfig;
|
||||
import fr.shinuwa.counterofshinuwa.core.*;
|
||||
import fr.shinuwa.counterofshinuwa.recipe.ItemKeys;
|
||||
import fr.shinuwa.counterofshinuwa.station.ProductionRegistry;
|
||||
import fr.shinuwa.counterofshinuwa.validation.ShopValidationResult;
|
||||
import java.util.*;
|
||||
import net.minecraft.server.level.*;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.inventory.ResultContainer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
public final class ShopStock {
|
||||
public static Map<String, Integer> collect(
|
||||
ServerLevel level, Shop shop, ShopSession session, ShopValidationResult structure) {
|
||||
return collect(level, shop, session, structure, false);
|
||||
}
|
||||
|
||||
public static Map<String, Integer> finished(
|
||||
ServerLevel level, Shop shop, ShopSession session, ShopValidationResult structure) {
|
||||
return collect(level, shop, session, structure, true);
|
||||
}
|
||||
|
||||
private static Map<String, Integer> collect(
|
||||
ServerLevel level,
|
||||
Shop shop,
|
||||
ShopSession session,
|
||||
ShopValidationResult structure,
|
||||
boolean finished) {
|
||||
Map<String, Integer> result = new HashMap<>();
|
||||
Set<Container> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (var pos : shop.storages)
|
||||
if (level.getBlockEntity(pos) instanceof Container c) add(c, seen, result, session, finished);
|
||||
ServerPlayer owner = level.getServer().getPlayerList().getPlayer(shop.owner);
|
||||
if (owner != null && owner.level() == level && shop.contains(owner.blockPosition())) {
|
||||
add(owner.getInventory(), seen, result, session, finished);
|
||||
// Menus keep crafting inputs out of the player inventory. Count real slots once, never
|
||||
// previews.
|
||||
for (var slot : owner.containerMenu.slots)
|
||||
if (!(slot.container instanceof ResultContainer)
|
||||
&& !(slot.container instanceof net.minecraft.world.level.block.entity.BlockEntity)
|
||||
&& slot.container != owner.getInventory())
|
||||
add(slot.container, seen, result, session, finished);
|
||||
addStack(owner.containerMenu.getCarried(), result, session, finished);
|
||||
}
|
||||
for (var entry : structure.stations().entrySet()) {
|
||||
var be = level.getBlockEntity(entry.getKey());
|
||||
var handler = ProductionRegistry.INSTANCE.handler(entry.getValue().handler());
|
||||
if (be != null && handler != null && (!(be instanceof Container c) || seen.add(c)))
|
||||
for (ItemStack stack : handler.stock(session, be))
|
||||
addStack(stack, result, session, finished);
|
||||
}
|
||||
if (session != null && level.getBlockEntity(shop.counter) instanceof Container c)
|
||||
add(c, seen, result, session, finished);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void add(
|
||||
Container c,
|
||||
Set<Container> seen,
|
||||
Map<String, Integer> out,
|
||||
ShopSession session,
|
||||
boolean finished) {
|
||||
if (seen.add(c))
|
||||
for (int i = 0; i < c.getContainerSize(); i++) addStack(c.getItem(i), out, session, finished);
|
||||
}
|
||||
|
||||
private static void addStack(
|
||||
ItemStack item, Map<String, Integer> out, ShopSession session, boolean finished) {
|
||||
if (item.isEmpty()) return;
|
||||
if (finished && (session == null || !ShopProductionTracker.valid(session, item))) return;
|
||||
ItemStack ordinary = item.copy();
|
||||
ordinary.remove(fr.shinuwa.counterofshinuwa.CounterOfShinuwa.PRODUCTION.get());
|
||||
if (!ItemStack.isSameItemSameComponents(ordinary, new ItemStack(item.getItem()))) return;
|
||||
if (ShopConfig.material(item) || session != null && ShopProductionTracker.valid(session, item))
|
||||
out.merge(ItemKeys.id(item), item.getCount(), Integer::sum);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package fr.shinuwa.counterofshinuwa.recipe;
|
||||
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
public final class ItemKeys {
|
||||
private ItemKeys() {}
|
||||
|
||||
public static String id(ItemStack stack) {
|
||||
return BuiltInRegistries.ITEM.getKey(stack.getItem()).toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package fr.shinuwa.counterofshinuwa.recipe;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/** A bounded, deterministic production edge; each ingredient consumes one matching item. */
|
||||
public record ProductionRecipe(
|
||||
String id,
|
||||
List<Set<String>> ingredients,
|
||||
String output,
|
||||
int count,
|
||||
Map<String, Integer> remainders,
|
||||
String station,
|
||||
String family,
|
||||
int ticks,
|
||||
boolean cooking) {
|
||||
public ProductionRecipe {
|
||||
ingredients = ingredients.stream().map(Set::copyOf).toList();
|
||||
remainders = Map.copyOf(remainders);
|
||||
if (count < 1 || ingredients.isEmpty() || ingredients.stream().anyMatch(Set::isEmpty))
|
||||
throw new IllegalArgumentException("Invalid deterministic recipe " + id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package fr.shinuwa.counterofshinuwa.recipe;
|
||||
|
||||
import java.util.List;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
|
||||
public interface RecipeProvider {
|
||||
List<ProductionRecipe> recipes(ServerLevel level);
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
package fr.shinuwa.counterofshinuwa.recipe;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/** Pure quantity planner. All branches work on copies; failed alternatives never consume stock. */
|
||||
public final class RecipeResolver {
|
||||
private final Map<String, List<ProductionRecipe>> byOutput = new TreeMap<>();
|
||||
private final Map<String, Integer> fuels;
|
||||
private final int maxDepth, budget;
|
||||
|
||||
public RecipeResolver(
|
||||
Collection<ProductionRecipe> recipes, Map<String, Integer> fuels, int maxDepth, int budget) {
|
||||
recipes.stream()
|
||||
.sorted(Comparator.comparing(ProductionRecipe::id))
|
||||
.forEach(r -> byOutput.computeIfAbsent(r.output(), ignored -> new ArrayList<>()).add(r));
|
||||
this.fuels = Map.copyOf(fuels);
|
||||
this.maxDepth = maxDepth;
|
||||
this.budget = budget;
|
||||
}
|
||||
|
||||
public Set<String> products() {
|
||||
return Collections.unmodifiableSet(byOutput.keySet());
|
||||
}
|
||||
|
||||
public record Plan(Map<String, Integer> remaining, List<String> steps, int ticks) {}
|
||||
|
||||
private static final class Work {
|
||||
Map<String, Integer> items;
|
||||
List<String> steps;
|
||||
int ticks, heat;
|
||||
|
||||
Work(Map<String, Integer> items) {
|
||||
this.items = new HashMap<>(items);
|
||||
this.steps = new ArrayList<>();
|
||||
}
|
||||
|
||||
Work copy() {
|
||||
Work c = new Work(items);
|
||||
c.steps.addAll(steps);
|
||||
c.ticks = ticks;
|
||||
c.heat = heat;
|
||||
return c;
|
||||
}
|
||||
|
||||
void adopt(Work c) {
|
||||
items = c.items;
|
||||
steps = c.steps;
|
||||
ticks = c.ticks;
|
||||
heat = c.heat;
|
||||
}
|
||||
|
||||
void add(String item, int count) {
|
||||
items.merge(item, count, Integer::sum);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Plan> plan(Map<String, Integer> requested, Map<String, Integer> stock) {
|
||||
return plan(requested, stock, Map.of());
|
||||
}
|
||||
|
||||
public Optional<Plan> plan(
|
||||
Map<String, Integer> requested, Map<String, Integer> stock, Map<String, Integer> finished) {
|
||||
Work w = new Work(stock);
|
||||
int[] left = {budget};
|
||||
for (var entry : new TreeMap<>(requested).entrySet()) {
|
||||
int ready =
|
||||
Math.min(
|
||||
entry.getValue(),
|
||||
Math.min(
|
||||
finished.getOrDefault(entry.getKey(), 0),
|
||||
w.items.getOrDefault(entry.getKey(), 0)));
|
||||
w.add(entry.getKey(), -ready);
|
||||
if (entry.getValue() <= 0
|
||||
|| entry.getValue() > 320
|
||||
|| !obtain(entry.getKey(), entry.getValue() - ready, w, new HashSet<>(), left, false))
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new Plan(Map.copyOf(w.items), List.copyOf(w.steps), w.ticks));
|
||||
}
|
||||
|
||||
private boolean obtain(String item, int count, Work w, Set<String> path, int[] left) {
|
||||
return obtain(item, count, w, path, left, true);
|
||||
}
|
||||
|
||||
private boolean obtain(
|
||||
String item, int count, Work w, Set<String> path, int[] left, boolean useStock) {
|
||||
if (--left[0] < 0 || path.size() > maxDepth) return false;
|
||||
int available = useStock ? Math.min(count, Math.max(0, w.items.getOrDefault(item, 0))) : 0;
|
||||
w.add(item, -available);
|
||||
count -= available;
|
||||
if (count == 0) return true;
|
||||
if (!path.add(item)) return false;
|
||||
try {
|
||||
for (ProductionRecipe recipe : byOutput.getOrDefault(item, List.of())) {
|
||||
Work candidate = w.copy();
|
||||
int batches = (count + recipe.count() - 1) / recipe.count();
|
||||
boolean ok = true;
|
||||
for (int b = 0; b < batches && ok; b++) {
|
||||
ok = ingredients(recipe.ingredients(), 0, candidate, path, left);
|
||||
if (ok && recipe.cooking()) ok = fuel(recipe.ticks(), candidate, path, left);
|
||||
if (ok) {
|
||||
recipe.remainders().forEach(candidate::add);
|
||||
candidate.steps.add(recipe.id());
|
||||
candidate.ticks += recipe.ticks();
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
candidate.add(item, batches * recipe.count() - count);
|
||||
w.adopt(candidate);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
path.remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean ingredients(
|
||||
List<Set<String>> inputs, int index, Work w, Set<String> path, int[] left) {
|
||||
if (index == inputs.size()) return true;
|
||||
for (String item : new TreeSet<>(inputs.get(index))) {
|
||||
Work c = w.copy();
|
||||
if (obtain(item, 1, c, path, left) && ingredients(inputs, index + 1, c, path, left)) {
|
||||
w.adopt(c);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean fuel(int ticks, Work w, Set<String> path, int[] left) {
|
||||
while (w.heat < ticks) {
|
||||
boolean found = false;
|
||||
for (var entry : new TreeMap<>(fuels).entrySet()) {
|
||||
if (entry.getValue() <= 0 || path.contains(entry.getKey())) continue;
|
||||
Work c = w.copy();
|
||||
if (obtain(entry.getKey(), 1, c, path, left)) {
|
||||
c.heat += entry.getValue();
|
||||
w.adopt(c);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) return false;
|
||||
}
|
||||
w.heat -= ticks;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package fr.shinuwa.counterofshinuwa.station;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.ShopConfig;
|
||||
import fr.shinuwa.counterofshinuwa.production.ProductionHandler;
|
||||
import fr.shinuwa.counterofshinuwa.recipe.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Add integrations during common setup. Recipe caches are invalidated at datapack reload. */
|
||||
public final class ProductionRegistry {
|
||||
public static final ProductionRegistry INSTANCE = new ProductionRegistry();
|
||||
private final List<StationProvider> stations = new ArrayList<>();
|
||||
private final List<RecipeProvider> providers = new ArrayList<>();
|
||||
private final Set<String> families = new HashSet<>();
|
||||
private final Map<String, ProductionHandler> handlers = new HashMap<>();
|
||||
private List<ProductionRecipe> cache;
|
||||
|
||||
public synchronized void register(
|
||||
StationProvider station,
|
||||
RecipeProvider recipes,
|
||||
StationFamilyProvider family,
|
||||
ProductionHandler... production) {
|
||||
stations.add(station);
|
||||
providers.add(recipes);
|
||||
families.addAll(family.families());
|
||||
for (var h : production) {
|
||||
if (handlers.putIfAbsent(h.id(), h) != null)
|
||||
throw new IllegalArgumentException("Duplicate production handler " + h.id());
|
||||
}
|
||||
cache = null;
|
||||
}
|
||||
|
||||
public Optional<StationDefinition> station(BlockState state) {
|
||||
for (var p : stations) {
|
||||
var d = p.station(state);
|
||||
if (d.isPresent()) return d;
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public List<StationDefinition> intrinsicStations() {
|
||||
return stations.stream()
|
||||
.flatMap(p -> p.intrinsicStations().stream())
|
||||
.filter(this::enabled)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public boolean enabled(StationDefinition d) {
|
||||
return families.contains(d.family())
|
||||
&& handlers.containsKey(d.handler())
|
||||
&& !ShopConfig.DISABLED_FAMILIES.get().contains(d.family())
|
||||
&& !ShopConfig.DISABLED_HANDLERS.get().contains(d.handler());
|
||||
}
|
||||
|
||||
public ProductionHandler handler(String id) {
|
||||
return ShopConfig.DISABLED_HANDLERS.get().contains(id) ? null : handlers.get(id);
|
||||
}
|
||||
|
||||
public List<ProductionRecipe> recipes(ServerLevel level) {
|
||||
if (cache == null) {
|
||||
Map<String, ProductionRecipe> all = new LinkedHashMap<>();
|
||||
for (var provider : providers)
|
||||
for (var r : provider.recipes(level)) {
|
||||
if (all.putIfAbsent(r.id(), r) != null)
|
||||
throw new IllegalStateException("Duplicate recipe " + r.id());
|
||||
}
|
||||
cache = List.copyOf(all.values());
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
public void invalidate() {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
public void resetProduction() {
|
||||
handlers.values().forEach(ProductionHandler::reset);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package fr.shinuwa.counterofshinuwa.station;
|
||||
|
||||
public record StationDefinition(String id, String family, String handler) {}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package fr.shinuwa.counterofshinuwa.station;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public interface StationFamilyProvider {
|
||||
Set<String> families();
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package fr.shinuwa.counterofshinuwa.station;
|
||||
|
||||
import java.util.Optional;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
public interface StationProvider {
|
||||
Optional<StationDefinition> station(BlockState state);
|
||||
|
||||
default java.util.List<StationDefinition> intrinsicStations() {
|
||||
return java.util.List.of();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package fr.shinuwa.counterofshinuwa.validation;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.station.StationDefinition;
|
||||
import java.util.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
|
||||
public record ShopValidationResult(
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
Map<BlockPos, StationDefinition> stations,
|
||||
Set<String> stationTypes,
|
||||
Set<String> families,
|
||||
List<BlockPos> incoming,
|
||||
List<BlockPos> outgoing,
|
||||
List<BlockPos> queue) {
|
||||
public ShopValidationResult {
|
||||
errors = List.copyOf(errors);
|
||||
warnings = List.copyOf(warnings);
|
||||
stations = Map.copyOf(stations);
|
||||
stationTypes = Set.copyOf(stationTypes);
|
||||
families = Set.copyOf(families);
|
||||
incoming = List.copyOf(incoming);
|
||||
outgoing = List.copyOf(outgoing);
|
||||
queue = List.copyOf(queue);
|
||||
}
|
||||
|
||||
public boolean valid() {
|
||||
return errors.isEmpty();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package fr.shinuwa.counterofshinuwa.validation;
|
||||
|
||||
import fr.shinuwa.counterofshinuwa.*;
|
||||
import fr.shinuwa.counterofshinuwa.core.*;
|
||||
import fr.shinuwa.counterofshinuwa.path.ShopPathManager;
|
||||
import fr.shinuwa.counterofshinuwa.station.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
|
||||
public final class ShopValidator {
|
||||
public static ShopValidationResult scan(ServerLevel level, Shop shop, Collection<Shop> shops) {
|
||||
List<String> errors = new ArrayList<>(), warnings = new ArrayList<>();
|
||||
Map<BlockPos, StationDefinition> stations = new LinkedHashMap<>();
|
||||
Set<String> types = new HashSet<>(), families = new HashSet<>();
|
||||
Set<BlockPos> blue = new HashSet<>(), red = new HashSet<>();
|
||||
if (shop.cornerA == null || shop.cornerB == null) errors.add("zone_missing");
|
||||
BlockPos min = shop.min(), max = shop.max();
|
||||
if (max.getX() - min.getX() + 1 > ShopConfig.MAX_WIDTH.get()
|
||||
|| max.getZ() - min.getZ() + 1 > ShopConfig.MAX_WIDTH.get()
|
||||
|| max.getY() - min.getY() + 1 > ShopConfig.MAX_HEIGHT.get()) errors.add("zone_large");
|
||||
if (!shop.contains(shop.counter)) errors.add("counter_outside");
|
||||
if (shops.stream().anyMatch(other -> !other.id.equals(shop.id) && shop.overlaps(other)))
|
||||
errors.add("zone_overlap");
|
||||
// Bound the area before checking chunks or reading blocks.
|
||||
if (!errors.isEmpty())
|
||||
return result(errors, warnings, stations, types, families, List.of(), List.of());
|
||||
if (!loaded(level, shop)) errors.add("chunks_unloaded");
|
||||
if (!errors.isEmpty())
|
||||
return result(errors, warnings, stations, types, families, List.of(), List.of());
|
||||
if (!level.getBlockState(shop.counter).is(CounterOfShinuwa.COUNTER.get()))
|
||||
errors.add("counter_missing");
|
||||
if (!level.getBlockState(shop.cornerA).is(CounterOfShinuwa.MARKER.get())
|
||||
|| !level.getBlockState(shop.cornerB).is(CounterOfShinuwa.MARKER.get()))
|
||||
errors.add("markers_missing");
|
||||
if (shop.storages.isEmpty()
|
||||
|| shop.storages.stream()
|
||||
.anyMatch(
|
||||
p ->
|
||||
!shop.contains(p)
|
||||
|| !level.getBlockState(p).is(CounterOfShinuwa.STORAGE.get())))
|
||||
errors.add("storage_missing");
|
||||
if (shop.entry == null
|
||||
|| !shop.contains(shop.entry)
|
||||
|| !level.getBlockState(shop.entry).is(CounterOfShinuwa.ACCESS.get()))
|
||||
errors.add("entry_missing");
|
||||
if (shop.exit == null
|
||||
|| !shop.contains(shop.exit)
|
||||
|| !level.getBlockState(shop.exit).is(CounterOfShinuwa.ACCESS.get()))
|
||||
errors.add("exit_missing");
|
||||
for (BlockPos access : new BlockPos[] {shop.entry, shop.exit})
|
||||
if (access != null
|
||||
&& shop.contains(access)
|
||||
&& (!level
|
||||
.getBlockState(access.above())
|
||||
.getCollisionShape(level, access.above())
|
||||
.isEmpty()
|
||||
|| !level
|
||||
.getBlockState(access.above(2))
|
||||
.getCollisionShape(level, access.above(2))
|
||||
.isEmpty())) errors.add("path_blocked");
|
||||
for (var intrinsic : ProductionRegistry.INSTANCE.intrinsicStations()) {
|
||||
types.add(intrinsic.id());
|
||||
families.add(intrinsic.family());
|
||||
}
|
||||
for (BlockPos mutable : BlockPos.betweenClosed(min, max)) {
|
||||
BlockPos p = mutable.immutable();
|
||||
var state = level.getBlockState(p);
|
||||
var definition = ProductionRegistry.INSTANCE.station(state);
|
||||
if (definition.isPresent()) {
|
||||
var d = definition.get();
|
||||
if (ProductionRegistry.INSTANCE.enabled(d)) {
|
||||
stations.put(p, d);
|
||||
types.add(d.id());
|
||||
families.add(d.family());
|
||||
} else warnings.add("disabled_station: " + p.toShortString());
|
||||
} else if (state.hasBlockEntity()
|
||||
&& !state.is(CounterOfShinuwa.COUNTER.get())
|
||||
&& !state.is(CounterOfShinuwa.STORAGE.get()))
|
||||
warnings.add(
|
||||
"unsupported_block: "
|
||||
+ net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(state.getBlock()));
|
||||
if (state.is(Blocks.BLUE_CARPET) || state.is(Blocks.RED_CARPET)) {
|
||||
if (!level.getBlockState(p.above()).getCollisionShape(level, p.above()).isEmpty()
|
||||
|| !level.getBlockState(p.above(2)).getCollisionShape(level, p.above(2)).isEmpty())
|
||||
errors.add("path_blocked");
|
||||
(state.is(Blocks.BLUE_CARPET) ? blue : red).add(p);
|
||||
}
|
||||
}
|
||||
if (ShopConfig.LIMIT_FAMILIES.get() && families.size() > ShopConfig.MAX_FAMILIES.get())
|
||||
errors.add("too_many_families");
|
||||
List<BlockPos> incoming = ShopPathManager.chain(blue, shop.entry, shop.counter);
|
||||
List<BlockPos> outgoing = ShopPathManager.chain(red, shop.counter, shop.exit);
|
||||
if (incoming.isEmpty()) errors.add("incoming_invalid");
|
||||
if (outgoing.isEmpty()) errors.add("outgoing_invalid");
|
||||
return result(errors, warnings, stations, types, families, incoming, outgoing);
|
||||
}
|
||||
|
||||
private static ShopValidationResult result(
|
||||
List<String> e,
|
||||
List<String> w,
|
||||
Map<BlockPos, StationDefinition> s,
|
||||
Set<String> t,
|
||||
Set<String> f,
|
||||
List<BlockPos> in,
|
||||
List<BlockPos> out) {
|
||||
return new ShopValidationResult(
|
||||
e.stream().distinct().toList(),
|
||||
w.stream().distinct().limit(32).toList(),
|
||||
s,
|
||||
t,
|
||||
f,
|
||||
in,
|
||||
out,
|
||||
ShopPathManager.queue(in));
|
||||
}
|
||||
|
||||
public static boolean loaded(ServerLevel level, Shop shop) {
|
||||
BlockPos a = shop.min(), b = shop.max();
|
||||
for (int x = a.getX() >> 4; x <= b.getX() >> 4; x++)
|
||||
for (int z = a.getZ() >> 4; z <= b.getZ() >> 4; z++) if (!level.hasChunk(x, z)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
106
src/main/resources/META-INF/neoforge.mods.toml
Normal file
106
src/main/resources/META-INF/neoforge.mods.toml
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# This is an example neoforge.mods.toml file. It contains the data relating to the loading mods.
|
||||
# There are several mandatory fields (#mandatory), and many more that are optional (#optional).
|
||||
# The overall format is standard TOML format, v0.5.0.
|
||||
# Note that there are a couple of TOML lists in this file.
|
||||
# Find more information on toml format here: https://github.com/toml-lang/toml
|
||||
|
||||
# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml
|
||||
modLoader="javafml" #mandatory
|
||||
|
||||
# A version range to match for said mod loader - for regular FML @Mod it will be the FML version. This is currently 2.
|
||||
loaderVersion="${loader_version_range}" #mandatory
|
||||
|
||||
# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties.
|
||||
# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here.
|
||||
license="${mod_license}"
|
||||
|
||||
# A URL to refer people to when problems occur with this mod
|
||||
#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional
|
||||
|
||||
# A list of mods - how many allowed here is determined by the individual mod loader
|
||||
[[mods]] #mandatory
|
||||
|
||||
# The modid of the mod
|
||||
modId="${mod_id}" #mandatory
|
||||
|
||||
# The version number of the mod
|
||||
version="${mod_version}" #mandatory
|
||||
|
||||
# A display name for the mod
|
||||
displayName="${mod_name}" #mandatory
|
||||
|
||||
# A URL to query for updates for this mod. See the JSON update specification https://docs.neoforged.net/docs/misc/updatechecker/
|
||||
#updateJSONURL="https://change.me.example.invalid/updates.json" #optional
|
||||
|
||||
# A URL for the "homepage" for this mod, displayed in the mod UI
|
||||
#displayURL="https://change.me.to.your.mods.homepage.example.invalid/" #optional
|
||||
|
||||
# A file name (in the root of the mod JAR) containing a logo for display
|
||||
|
||||
# A text field displayed in the mod UI
|
||||
#credits="" #optional
|
||||
|
||||
# The authors of the mod, displayed in the mod UI (optional)
|
||||
#authors=""
|
||||
|
||||
# The description text for the mod (multi line!) (#mandatory)
|
||||
description='''
|
||||
Build a shop, craft orders and serve Easy NPC customers in Standard or Chill sessions.
|
||||
'''
|
||||
|
||||
# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded.
|
||||
#[[mixins]]
|
||||
#config="${mod_id}.mixins.json"
|
||||
|
||||
# The [[accessTransformers]] block allows you to declare where your AT file is.
|
||||
# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg
|
||||
#[[accessTransformers]]
|
||||
#file="META-INF/accesstransformer.cfg"
|
||||
|
||||
# The coremods config file path is not configurable and is always loaded from META-INF/coremods.json
|
||||
|
||||
# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional.
|
||||
[[dependencies.${mod_id}]] #optional
|
||||
# the modid of the dependency
|
||||
modId="neoforge" #mandatory
|
||||
# The type of the dependency. Can be one of "required", "optional", "incompatible" or "discouraged" (case insensitive).
|
||||
# 'required' requires the mod to exist, 'optional' does not
|
||||
# 'incompatible' will prevent the game from loading when the mod exists, and 'discouraged' will show a warning
|
||||
type="required" #mandatory
|
||||
# Optional field describing why the dependency is required or why it is incompatible
|
||||
# reason="..."
|
||||
# The version range of the dependency
|
||||
versionRange="[${neo_version},)" #mandatory
|
||||
# An ordering relationship for the dependency.
|
||||
# BEFORE - This mod is loaded BEFORE the dependency
|
||||
# AFTER - This mod is loaded AFTER the dependency
|
||||
ordering="NONE"
|
||||
# Side this dependency is applied on - BOTH, CLIENT, or SERVER
|
||||
side="BOTH"
|
||||
|
||||
# Here's another dependency
|
||||
[[dependencies.${mod_id}]]
|
||||
modId="minecraft"
|
||||
type="required"
|
||||
# This version range declares a minimum of the current minecraft version up to but not including the next major version
|
||||
versionRange="${minecraft_version_range}"
|
||||
ordering="NONE"
|
||||
side="BOTH"
|
||||
|
||||
# Features are specific properties of the game environment, that you may want to declare you require. This example declares
|
||||
# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't
|
||||
# stop your mod loading on the server for example.
|
||||
#[features.${mod_id}]
|
||||
#openGLVersion="[3.2,)"
|
||||
|
||||
[[dependencies.${mod_id}]]
|
||||
modId="easy_npc"
|
||||
type="required"
|
||||
# Upstream 7.12.1 publishes no Implementation-Version and reports 0.0NONE.
|
||||
# Gradle pins the published artifact e5wGcNT7; retain the exact metadata exception.
|
||||
versionRange="[0.0NONE],[7.12.1,7.13)"
|
||||
ordering="AFTER"
|
||||
side="BOTH"
|
||||
|
||||
[[mixins]]
|
||||
config="counter_of_shinuwa.mixins.json"
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "counter_of_shinuwa:block/access_point"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "counter_of_shinuwa:block/counter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "counter_of_shinuwa:block/material_storage"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "counter_of_shinuwa:block/zone_marker"
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/main/resources/assets/counter_of_shinuwa/lang/en_us.json
Normal file
86
src/main/resources/assets/counter_of_shinuwa/lang/en_us.json
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"itemGroup.counter_of_shinuwa": "Counter of Shinuwa",
|
||||
"block.counter_of_shinuwa.counter": "Shop counter",
|
||||
"block.counter_of_shinuwa.material_storage": "Material storage",
|
||||
"block.counter_of_shinuwa.zone_marker": "Zone marker",
|
||||
"block.counter_of_shinuwa.access_point": "Entrance / exit",
|
||||
"item.counter_of_shinuwa.linking_tool": "Linking tool",
|
||||
"cos.customer": "Customer",
|
||||
"cos.link.selected": "Counter selected. Link markers, storage and entrance.",
|
||||
"cos.link.linked": "Link saved.",
|
||||
"cos.link.denied": "You do not own this shop.",
|
||||
"cos.link.close_first": "Close the shop before changing links.",
|
||||
"cos.link.already_linked": "This block belongs to another shop.",
|
||||
"cos.link.invalid": "Select a marker, storage or entrance.",
|
||||
"cos.state.CLOSED": "Shop closed",
|
||||
"cos.state.OPEN": "Shop open",
|
||||
"cos.state.ARRIVALS_CLOSED": "Serving remaining customers",
|
||||
"cos.ui.open": "Open shop",
|
||||
"cos.ui.standard": "Standard",
|
||||
"cos.ui.chill": "Chill",
|
||||
"cos.ui.confirm": "Confirm opening",
|
||||
"cos.ui.close_arrivals": "Close arrivals",
|
||||
"cos.ui.force_close": "Force close",
|
||||
"cos.ui.refuse": "Refuse order",
|
||||
"cos.ui.deliver": "Deliver order",
|
||||
"cos.ui.withdraw": "Collect emeralds",
|
||||
"cos.ui.verify": "Inspect shop",
|
||||
"cos.ui.catalogue": "View / catalogue / inspection",
|
||||
"cos.ui.reputation": "Reputation: %s",
|
||||
"cos.ui.products": "Catalogue: %s products",
|
||||
"cos.ui.families": "Specializations: %s",
|
||||
"cos.ui.valid": "Shop valid",
|
||||
"cos.ui.preparing": "Session preparation",
|
||||
"cos.ui.real_stock": "Stock: real quantities",
|
||||
"cos.ui.snapshot": "Stock: opening types",
|
||||
"cos.ui.multiplier": "Rewards ×%s",
|
||||
"cos.ui.frozen": "Reputation frozen",
|
||||
"cos.ui.progression": "Progression active",
|
||||
"cos.ui.queue": "Customers: %s",
|
||||
"cos.ui.reward": "Reward: %s emeralds",
|
||||
"cos.ui.patience": "Patience: %s seconds",
|
||||
"cos.ui.no_order": "No active order",
|
||||
"cos.ui.credit": "Unclaimed: %s emeralds",
|
||||
"cos.error.zone_missing": "Link both zone markers.",
|
||||
"cos.error.zone_large": "Zone exceeds the size limit.",
|
||||
"cos.error.counter_outside": "Counter is outside the zone.",
|
||||
"cos.error.zone_overlap": "Shop zones overlap.",
|
||||
"cos.error.chunks_unloaded": "Part of the zone is unloaded.",
|
||||
"cos.error.counter_missing": "Counter missing.",
|
||||
"cos.error.markers_missing": "Markers missing.",
|
||||
"cos.error.storage_missing": "Link a storage inside the zone.",
|
||||
"cos.error.entry_missing": "Entrance missing or outside zone.",
|
||||
"cos.error.exit_missing": "Exit missing or outside zone.",
|
||||
"cos.error.path_blocked": "Clear the space above the path.",
|
||||
"cos.error.too_many_families": "Too many specializations.",
|
||||
"cos.error.incoming_invalid": "Blue path is missing or ambiguous.",
|
||||
"cos.error.outgoing_invalid": "Red path is missing or ambiguous.",
|
||||
"cos.message.denied": "Only the owner may do this.",
|
||||
"cos.message.already_open": "You already have an open shop.",
|
||||
"cos.message.invalid_shop": "Check the shop inspection report.",
|
||||
"cos.message.empty_catalogue": "No products can be made with this stock.",
|
||||
"cos.message.opened": "Your shop is open!",
|
||||
"cos.message.delivery_invalid": "Missing items or invalid provenance.",
|
||||
"cos.npc.hello": "Hello!",
|
||||
"cos.npc.order": "I would like to place an order.",
|
||||
"cos.npc.success": "Thank you! See you soon.",
|
||||
"cos.npc.refused": "Another time, then.",
|
||||
"cos.npc.timeout": "I cannot wait any longer.",
|
||||
"cos.npc.no_stock": "I will return after you restock.",
|
||||
"cos.rank.0": "Unknown",
|
||||
"cos.rank.1": "Small shopkeeper",
|
||||
"cos.rank.2": "Merchant",
|
||||
"cos.rank.3": "Respected merchant",
|
||||
"cos.rank.4": "Renowned merchant",
|
||||
"cos.rank.5": "Royal supplier",
|
||||
"cos.message.forget_counter": "Load the chunk and remove the counter before forgetting this shop.",
|
||||
"cos.message.forget_credit": "Make inventory space for the remaining credit, then try again.",
|
||||
"cos.message.forgotten": "Shop forgotten. Reputation retained.",
|
||||
"cos.family.counter_of_shinuwa:crafting": "Crafting",
|
||||
"cos.family.counter_of_shinuwa:forge": "Forge",
|
||||
"cos.family.counter_of_shinuwa:cooking": "Cooking",
|
||||
"cos.family.counter_of_shinuwa:stoneworking": "Stoneworking",
|
||||
"cos.family.counter_of_shinuwa:smithing": "Smithing",
|
||||
"cos.warning.disabled_station": "Disabled station: %s",
|
||||
"cos.warning.unsupported_block": "Unsupported block: %s"
|
||||
}
|
||||
86
src/main/resources/assets/counter_of_shinuwa/lang/fr_fr.json
Normal file
86
src/main/resources/assets/counter_of_shinuwa/lang/fr_fr.json
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"itemGroup.counter_of_shinuwa": "Counter of Shinuwa",
|
||||
"block.counter_of_shinuwa.counter": "Comptoir",
|
||||
"block.counter_of_shinuwa.material_storage": "Stockage de matières",
|
||||
"block.counter_of_shinuwa.zone_marker": "Marqueur de zone",
|
||||
"block.counter_of_shinuwa.access_point": "Entrée / sortie",
|
||||
"item.counter_of_shinuwa.linking_tool": "Outil de liaison",
|
||||
"cos.customer": "Client",
|
||||
"cos.link.selected": "Comptoir sélectionné. Reliez les marqueurs, le stockage et l’entrée.",
|
||||
"cos.link.linked": "Liaison enregistrée.",
|
||||
"cos.link.denied": "Cette boutique ne vous appartient pas.",
|
||||
"cos.link.close_first": "Fermez la boutique avant de modifier ses liaisons.",
|
||||
"cos.link.already_linked": "Ce bloc appartient déjà à une boutique.",
|
||||
"cos.link.invalid": "Sélectionnez un marqueur, un stockage ou une entrée.",
|
||||
"cos.state.CLOSED": "Boutique fermée",
|
||||
"cos.state.OPEN": "Boutique ouverte",
|
||||
"cos.state.ARRIVALS_CLOSED": "Derniers clients",
|
||||
"cos.ui.open": "Ouvrir la boutique",
|
||||
"cos.ui.standard": "Standard",
|
||||
"cos.ui.chill": "Chill",
|
||||
"cos.ui.confirm": "Confirmer l’ouverture",
|
||||
"cos.ui.close_arrivals": "Arrêter les arrivées",
|
||||
"cos.ui.force_close": "Fermer immédiatement",
|
||||
"cos.ui.refuse": "Refuser la commande",
|
||||
"cos.ui.deliver": "Livrer la commande",
|
||||
"cos.ui.withdraw": "Récupérer les émeraudes",
|
||||
"cos.ui.verify": "Vérifier la boutique",
|
||||
"cos.ui.catalogue": "Vue / catalogue / diagnostic",
|
||||
"cos.ui.reputation": "Réputation : %s",
|
||||
"cos.ui.products": "Catalogue : %s produits",
|
||||
"cos.ui.families": "Spécialisations : %s",
|
||||
"cos.ui.valid": "Boutique valide",
|
||||
"cos.ui.preparing": "Préparation de session",
|
||||
"cos.ui.real_stock": "Stock : quantités réelles",
|
||||
"cos.ui.snapshot": "Stock : types à l’ouverture",
|
||||
"cos.ui.multiplier": "Récompenses ×%s",
|
||||
"cos.ui.frozen": "Réputation figée",
|
||||
"cos.ui.progression": "Progression active",
|
||||
"cos.ui.queue": "Clients : %s",
|
||||
"cos.ui.reward": "Récompense : %s émeraudes",
|
||||
"cos.ui.patience": "Patience : %s secondes",
|
||||
"cos.ui.no_order": "Aucune commande active",
|
||||
"cos.ui.credit": "À récupérer : %s émeraudes",
|
||||
"cos.error.zone_missing": "Reliez les deux marqueurs.",
|
||||
"cos.error.zone_large": "La zone dépasse la limite.",
|
||||
"cos.error.counter_outside": "Comptoir hors de la zone.",
|
||||
"cos.error.zone_overlap": "Deux boutiques se chevauchent.",
|
||||
"cos.error.chunks_unloaded": "Une partie de la zone est déchargée.",
|
||||
"cos.error.counter_missing": "Comptoir manquant.",
|
||||
"cos.error.markers_missing": "Marqueurs manquants.",
|
||||
"cos.error.storage_missing": "Reliez un stockage dans la zone.",
|
||||
"cos.error.entry_missing": "Entrée manquante ou hors zone.",
|
||||
"cos.error.exit_missing": "Sortie manquante ou hors zone.",
|
||||
"cos.error.path_blocked": "Libérez l’espace au-dessus du chemin.",
|
||||
"cos.error.too_many_families": "Trop de spécialisations.",
|
||||
"cos.error.incoming_invalid": "Chemin bleu absent ou ambigu.",
|
||||
"cos.error.outgoing_invalid": "Chemin rouge absent ou ambigu.",
|
||||
"cos.message.denied": "Accès réservé au propriétaire.",
|
||||
"cos.message.already_open": "Vous avez déjà une boutique ouverte.",
|
||||
"cos.message.invalid_shop": "Consultez le diagnostic de la boutique.",
|
||||
"cos.message.empty_catalogue": "Aucun produit réalisable avec ce stock.",
|
||||
"cos.message.opened": "La boutique est ouverte !",
|
||||
"cos.message.delivery_invalid": "Objets manquants ou provenance invalide.",
|
||||
"cos.npc.hello": "Bonjour !",
|
||||
"cos.npc.order": "Je voudrais passer une commande.",
|
||||
"cos.npc.success": "Merci, à bientôt !",
|
||||
"cos.npc.refused": "Une autre fois, alors.",
|
||||
"cos.npc.timeout": "Je ne peux plus attendre.",
|
||||
"cos.npc.no_stock": "Je repasserai après le réapprovisionnement.",
|
||||
"cos.rank.0": "Inconnu",
|
||||
"cos.rank.1": "Petit commerçant",
|
||||
"cos.rank.2": "Marchand",
|
||||
"cos.rank.3": "Commerçant réputé",
|
||||
"cos.rank.4": "Marchand renommé",
|
||||
"cos.rank.5": "Fournisseur royal",
|
||||
"cos.message.forget_counter": "Chargez le chunk et retirez le comptoir avant de supprimer la boutique.",
|
||||
"cos.message.forget_credit": "Libérez de la place pour récupérer le crédit restant, puis recommencez.",
|
||||
"cos.message.forgotten": "Boutique supprimée. Réputation conservée.",
|
||||
"cos.family.counter_of_shinuwa:crafting": "Fabrication",
|
||||
"cos.family.counter_of_shinuwa:forge": "Forge",
|
||||
"cos.family.counter_of_shinuwa:cooking": "Cuisine",
|
||||
"cos.family.counter_of_shinuwa:stoneworking": "Taille de pierre",
|
||||
"cos.family.counter_of_shinuwa:smithing": "Amélioration",
|
||||
"cos.warning.disabled_station": "Station désactivée : %s",
|
||||
"cos.warning.unsupported_block": "Bloc non pris en charge : %s"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"parent": "minecraft:block/pressure_plate_up",
|
||||
"textures": {
|
||||
"texture": "minecraft:block/purple_concrete"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"parent": "minecraft:block/cube",
|
||||
"textures": {
|
||||
"particle": "minecraft:block/oak_planks",
|
||||
"down": "minecraft:block/oak_planks",
|
||||
"up": "minecraft:block/dark_oak_planks",
|
||||
"north": "minecraft:block/bookshelf",
|
||||
"south": "minecraft:block/bookshelf",
|
||||
"east": "minecraft:block/oak_planks",
|
||||
"west": "minecraft:block/oak_planks"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": {
|
||||
"all": "minecraft:block/barrel_side"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": {
|
||||
"all": "minecraft:block/cyan_glazed_terracotta"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"parent": "counter_of_shinuwa:block/access_point"
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"parent": "counter_of_shinuwa:block/counter"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"parent": "minecraft:item/handheld",
|
||||
"textures": {
|
||||
"layer0": "minecraft:item/amethyst_shard"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"parent": "counter_of_shinuwa:block/material_storage"
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"parent": "counter_of_shinuwa:block/zone_marker"
|
||||
}
|
||||
20
src/main/resources/counter_of_shinuwa.mixins.json
Normal file
20
src/main/resources/counter_of_shinuwa.mixins.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"required": true,
|
||||
"minVersion": "0.8",
|
||||
"package": "fr.shinuwa.counterofshinuwa.mixin",
|
||||
"compatibilityLevel": "JAVA_21",
|
||||
"mixins": [
|
||||
"MenuExtractionMixin",
|
||||
"ResultContainerMixin",
|
||||
"CraftingRemainderMixin",
|
||||
"CraftingMenuAccess",
|
||||
"StonecutterMenuAccess",
|
||||
"ItemCombinerMenuAccess",
|
||||
"FurnaceAccess",
|
||||
"FurnaceProductionMixin",
|
||||
"HopperMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"type": "minecraft:block",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "counter_of_shinuwa:access_point"
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "minecraft:survives_explosion"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"type": "minecraft:block",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "counter_of_shinuwa:counter"
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "minecraft:survives_explosion"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"type": "minecraft:block",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "counter_of_shinuwa:material_storage"
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "minecraft:survives_explosion"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"type": "minecraft:block",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "counter_of_shinuwa:zone_marker"
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "minecraft:survives_explosion"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"type": "minecraft:crafting_shaped",
|
||||
"category": "misc",
|
||||
"pattern": [
|
||||
"RR",
|
||||
"SS"
|
||||
],
|
||||
"key": {
|
||||
"R": {
|
||||
"item": "minecraft:redstone"
|
||||
},
|
||||
"S": {
|
||||
"item": "minecraft:smooth_stone"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"id": "counter_of_shinuwa:access_point",
|
||||
"count": 2
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"type": "minecraft:crafting_shaped",
|
||||
"category": "misc",
|
||||
"pattern": [
|
||||
"SSS",
|
||||
"PCP",
|
||||
"PPP"
|
||||
],
|
||||
"key": {
|
||||
"S": {
|
||||
"tag": "minecraft:wooden_slabs"
|
||||
},
|
||||
"P": {
|
||||
"tag": "minecraft:planks"
|
||||
},
|
||||
"C": {
|
||||
"item": "minecraft:crafting_table"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"id": "counter_of_shinuwa:counter",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"type": "minecraft:crafting_shaped",
|
||||
"category": "misc",
|
||||
"pattern": [
|
||||
" A",
|
||||
"S "
|
||||
],
|
||||
"key": {
|
||||
"A": {
|
||||
"item": "minecraft:amethyst_shard"
|
||||
},
|
||||
"S": {
|
||||
"item": "minecraft:stick"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"id": "counter_of_shinuwa:linking_tool",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"type": "minecraft:crafting_shaped",
|
||||
"category": "misc",
|
||||
"pattern": [
|
||||
"PPP",
|
||||
"PCP",
|
||||
"PPP"
|
||||
],
|
||||
"key": {
|
||||
"P": {
|
||||
"tag": "minecraft:planks"
|
||||
},
|
||||
"C": {
|
||||
"item": "minecraft:chest"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"id": "counter_of_shinuwa:material_storage",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"type": "minecraft:crafting_shaped",
|
||||
"category": "misc",
|
||||
"pattern": [
|
||||
" D ",
|
||||
"SRS",
|
||||
" S "
|
||||
],
|
||||
"key": {
|
||||
"D": {
|
||||
"item": "minecraft:lapis_lazuli"
|
||||
},
|
||||
"S": {
|
||||
"item": "minecraft:cobblestone"
|
||||
},
|
||||
"R": {
|
||||
"item": "minecraft:redstone"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"id": "counter_of_shinuwa:zone_marker",
|
||||
"count": 2
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"counter_of_shinuwa:counter",
|
||||
"counter_of_shinuwa:material_storage"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"counter_of_shinuwa:zone_marker",
|
||||
"counter_of_shinuwa:access_point"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package fr.shinuwa.counterofshinuwa.gameplay;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GameplayRulesTest {
|
||||
@Test
|
||||
void chillFreezesReputationForAllOutcomes() {
|
||||
var rules =
|
||||
ShopGameplayRules.forMode(
|
||||
ShopGameplayMode.CHILL, ShopGameplayRules.RuleSettings.defaults());
|
||||
int reputation = 500;
|
||||
for (int i = 0; i < 10; i++) reputation += rules.reputationDelta(OrderOutcome.SUCCESS);
|
||||
for (int i = 0; i < 3; i++) reputation += rules.reputationDelta(OrderOutcome.REFUSED);
|
||||
for (int i = 0; i < 2; i++) reputation += rules.reputationDelta(OrderOutcome.TIMEOUT);
|
||||
reputation += rules.reputationDelta(OrderOutcome.FORCE_CLOSE);
|
||||
assertEquals(500, reputation);
|
||||
assertTrue(rules.usesQuantities(1000));
|
||||
assertEquals(.6, rules.rewardMultiplier());
|
||||
}
|
||||
|
||||
@Test
|
||||
void standardProgressionAndStockThreshold() {
|
||||
var rules = new StandardGameplayRules(ShopGameplayRules.RuleSettings.defaults());
|
||||
assertTrue(rules.usesQuantities(449));
|
||||
assertFalse(rules.usesQuantities(450));
|
||||
assertEquals(2, rules.reputationDelta(OrderOutcome.SUCCESS));
|
||||
assertEquals(-2, rules.reputationDelta(OrderOutcome.REFUSED));
|
||||
assertEquals(-5, rules.reputationDelta(OrderOutcome.TIMEOUT));
|
||||
assertEquals(-10, rules.reputationDelta(OrderOutcome.FORCE_CLOSE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void chillIsCalmerRegardlessOfReputation() {
|
||||
var settings = ShopGameplayRules.RuleSettings.defaults();
|
||||
var chill = new ChillGameplayRules(settings);
|
||||
var standard = new StandardGameplayRules(settings);
|
||||
assertTrue(chill.patienceTicks(800) > standard.patienceTicks(800));
|
||||
assertTrue(chill.spawnInterval(1000) > standard.spawnInterval(1000));
|
||||
assertEquals(chill.maxQuantity(0), chill.maxQuantity(1000));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package fr.shinuwa.counterofshinuwa.gameplay;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ShopPricingTest {
|
||||
@Test
|
||||
void multiplierPrecedesSingleFinalRounding() {
|
||||
assertEquals(2, ShopPricing.reward(Map.of("bread", 1), id -> 3, 1, .5, .6));
|
||||
}
|
||||
|
||||
@Test
|
||||
void priceUsesEveryLineAndQuantity() {
|
||||
var prices = Map.of("sword", 6, "torch", 2);
|
||||
assertEquals(24, ShopPricing.reward(Map.of("sword", 3, "torch", 2), prices::get, 5, .5, 1));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package fr.shinuwa.counterofshinuwa.recipe;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RecipeResolverTest {
|
||||
private static ProductionRecipe recipe(String out, int count, String... in) {
|
||||
return new ProductionRecipe(
|
||||
out,
|
||||
Arrays.stream(in).map(Set::of).toList(),
|
||||
out,
|
||||
count,
|
||||
Map.of(),
|
||||
"crafting",
|
||||
"crafting",
|
||||
20,
|
||||
false);
|
||||
}
|
||||
|
||||
private static RecipeResolver resolver(ProductionRecipe... recipes) {
|
||||
return new RecipeResolver(List.of(recipes), Map.of("coal", 1600), 12, 10000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recursiveProductionUsesBatchRemainders() {
|
||||
var r =
|
||||
resolver(
|
||||
recipe("plank", 4, "log"),
|
||||
recipe("stick", 4, "plank", "plank"),
|
||||
recipe("sword", 1, "iron", "iron", "stick"));
|
||||
var plan = r.plan(Map.of("sword", 2), Map.of("log", 1, "iron", 4)).orElseThrow();
|
||||
assertEquals(2, plan.remaining().get("stick"));
|
||||
assertEquals(2, plan.remaining().get("plank"));
|
||||
assertTrue(r.plan(Map.of("sword", 3), Map.of("log", 1, "iron", 4)).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rawFinishedItemCannotSatisfyAnOrderWithoutTransformation() {
|
||||
var r = resolver(recipe("ingot", 1, "ore"));
|
||||
assertTrue(r.plan(Map.of("ingot", 1), Map.of("ingot", 1)).isEmpty());
|
||||
assertTrue(r.plan(Map.of("ingot", 1), Map.of("ingot", 1), Map.of("ingot", 1)).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedIngredientsAreNotDoubleCounted() {
|
||||
var r = resolver(recipe("a", 1, "iron", "iron"), recipe("b", 1, "iron"));
|
||||
assertTrue(r.plan(Map.of("a", 1, "b", 1), Map.of("iron", 2)).isEmpty());
|
||||
assertTrue(r.plan(Map.of("a", 1, "b", 1), Map.of("iron", 3)).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void alternativesBacktrackWithoutLeakingResources() {
|
||||
var out =
|
||||
new ProductionRecipe(
|
||||
"mixed",
|
||||
List.of(Set.of("oak", "birch"), Set.of("birch")),
|
||||
"out",
|
||||
1,
|
||||
Map.of(),
|
||||
"crafting",
|
||||
"crafting",
|
||||
20,
|
||||
false);
|
||||
assertTrue(resolver(out).plan(Map.of("out", 1), Map.of("oak", 1, "birch", 1)).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cyclesTerminateAndSeededReversibleRecipesWork() {
|
||||
var r = resolver(recipe("a", 1, "b"), recipe("b", 1, "a"));
|
||||
assertTrue(r.plan(Map.of("a", 1), Map.of()).isEmpty());
|
||||
assertTrue(r.plan(Map.of("a", 1), Map.of("b", 1)).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cookingRequiresEnoughFuelForEveryBatch() {
|
||||
var r =
|
||||
resolver(
|
||||
new ProductionRecipe(
|
||||
"smelt",
|
||||
List.of(Set.of("ore")),
|
||||
"ingot",
|
||||
1,
|
||||
Map.of(),
|
||||
"furnace",
|
||||
"forge",
|
||||
200,
|
||||
true));
|
||||
assertTrue(r.plan(Map.of("ingot", 8), Map.of("ore", 8, "coal", 1)).isPresent());
|
||||
assertTrue(r.plan(Map.of("ingot", 9), Map.of("ore", 9, "coal", 1)).isEmpty());
|
||||
assertTrue(r.plan(Map.of("ingot", 1), Map.of("ore", 1)).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPlanningDoesNotMutateInputStock() {
|
||||
Map<String, Integer> stock = new HashMap<>(Map.of("iron", 1));
|
||||
resolver(recipe("sword", 1, "iron", "iron")).plan(Map.of("sword", 1), stock);
|
||||
assertEquals(Map.of("iron", 1), stock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void alternativesAndReusableContainers() {
|
||||
var r =
|
||||
resolver(
|
||||
new ProductionRecipe(
|
||||
"cake",
|
||||
List.of(Set.of("milk")),
|
||||
"cake",
|
||||
1,
|
||||
Map.of("bucket", 1),
|
||||
"crafting",
|
||||
"crafting",
|
||||
20,
|
||||
false),
|
||||
recipe("other", 1, "bucket"));
|
||||
assertTrue(r.plan(new TreeMap<>(Map.of("cake", 1, "other", 1)), Map.of("milk", 1)).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recipeBudgetFailsClosed() {
|
||||
var r = new RecipeResolver(List.of(recipe("a", 1, "b"), recipe("b", 1, "c")), Map.of(), 1, 1);
|
||||
assertTrue(r.plan(Map.of("a", 1), Map.of("c", 1)).isEmpty());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue