Initial commit
Some checks failed
Build / build (push) Has been cancelled

This commit is contained in:
Shinuwa 2026-09-18 14:54:32 +02:00
commit 07eb6dc053
318 changed files with 339857 additions and 0 deletions

View file

@ -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();
}
}
}
}

View 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"

View file

@ -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);
});
}
}

View file

@ -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;
}
}

View 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"

View 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);
}
}

View 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();
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View 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);
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View 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;
}
}

View 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;
}
}

View file

@ -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;
}))));
}
}

View 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);
}
}

View 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);
}
}

View file

@ -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));
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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());
}
}

View file

@ -0,0 +1,8 @@
package fr.shinuwa.counterofshinuwa.gameplay;
public enum OrderOutcome {
SUCCESS,
REFUSED,
TIMEOUT,
FORCE_CLOSE
}

View file

@ -0,0 +1,6 @@
package fr.shinuwa.counterofshinuwa.gameplay;
public enum ShopGameplayMode {
STANDARD,
CHILL
}

View file

@ -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);
}
}
}

View file

@ -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)));
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View 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);
}
}

View file

@ -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();
}

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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();
}

View file

@ -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();
}
}
}

View file

@ -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());
}
}

View file

@ -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();
}

View file

@ -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();
});
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,7 @@
package fr.shinuwa.counterofshinuwa.production;
import net.minecraft.world.inventory.ContainerLevelAccess;
public interface MenuAccess {
ContainerLevelAccess cosAccess();
}

View file

@ -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) {}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,3 @@
package fr.shinuwa.counterofshinuwa.station;
public record StationDefinition(String id, String family, String handler) {}

View file

@ -0,0 +1,7 @@
package fr.shinuwa.counterofshinuwa.station;
import java.util.Set;
public interface StationFamilyProvider {
Set<String> families();
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}

View 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"

View file

@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "counter_of_shinuwa:block/access_point"
}
}
}

View file

@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "counter_of_shinuwa:block/counter"
}
}
}

View file

@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "counter_of_shinuwa:block/material_storage"
}
}
}

View file

@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "counter_of_shinuwa:block/zone_marker"
}
}
}

View 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"
}

View 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"
}

View file

@ -0,0 +1,6 @@
{
"parent": "minecraft:block/pressure_plate_up",
"textures": {
"texture": "minecraft:block/purple_concrete"
}
}

View file

@ -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"
}
}

View file

@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "minecraft:block/barrel_side"
}
}

View file

@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "minecraft:block/cyan_glazed_terracotta"
}
}

View file

@ -0,0 +1,3 @@
{
"parent": "counter_of_shinuwa:block/access_point"
}

View file

@ -0,0 +1,3 @@
{
"parent": "counter_of_shinuwa:block/counter"
}

View file

@ -0,0 +1,6 @@
{
"parent": "minecraft:item/handheld",
"textures": {
"layer0": "minecraft:item/amethyst_shard"
}
}

View file

@ -0,0 +1,3 @@
{
"parent": "counter_of_shinuwa:block/material_storage"
}

View file

@ -0,0 +1,3 @@
{
"parent": "counter_of_shinuwa:block/zone_marker"
}

View 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
}
}

View file

@ -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"
}
]
}
]
}

View file

@ -0,0 +1,19 @@
{
"type": "minecraft:block",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "counter_of_shinuwa:counter"
}
],
"conditions": [
{
"condition": "minecraft:survives_explosion"
}
]
}
]
}

View file

@ -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"
}
]
}
]
}

View file

@ -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"
}
]
}
]
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"counter_of_shinuwa:counter",
"counter_of_shinuwa:material_storage"
]
}

View file

@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"counter_of_shinuwa:zone_marker",
"counter_of_shinuwa:access_point"
]
}

View file

@ -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));
}
}

View file

@ -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));
}
}

View file

@ -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());
}
}