From 0bc96c6301a47f464510fd36938cc22b4d83f197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Mar 2026 09:53:34 +0100 Subject: [PATCH 1/8] add group dungeons WIP --- .../gameserver/minigame/GroupDungeon.java | 246 ++++++++++++++++++ .../minigame/GroupDungeonConfig.java | 21 ++ .../gameserver/player/TradeSession.java | 4 + 3 files changed, 271 insertions(+) create mode 100644 gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java create mode 100644 gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java diff --git a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java new file mode 100644 index 00000000..ef17fd25 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java @@ -0,0 +1,246 @@ +package brainwine.gameserver.minigame; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.entity.EntityAttack; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.item.Layer; +import brainwine.gameserver.loot.Loot; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.player.TradeSession; +import brainwine.gameserver.zone.Block; +import brainwine.gameserver.zone.MetaBlock; +import brainwine.gameserver.zone.Zone; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public class GroupDungeon extends Minigame { + GroupDungeonConfig config = new GroupDungeonConfig(); + private final String sirenOpenId = "mechanical/siren-open"; + private final String speakerId = "mechanical/speaker"; + private final Set doorIds = new HashSet<>(Arrays.asList("mechanical/door-beefy-closed-iron", "mechanical/door-beefy-closed-copper")); + private final List whistles = Arrays.asList(ItemRegistry.getItem("accessories/whistle-onyx"), ItemRegistry.getItem("accessories/whistle-diamond"), ItemRegistry.getItem("accessories/whistle-brass")); + + boolean started = false; + int potencyLevel = 0; + Set potencyBumps = new HashSet<>(); + List speakers = new ArrayList<>(); + List doors = new ArrayList<>(); + int initialNumSpeakers = 0; + private final List spawns = new ArrayList<>(); + + public GroupDungeon(Zone zone, Player initiator, int x, int y) { + super(zone, initiator, x, y); + MetaBlock siren = zone.getMetaBlock(x, y); + if(siren != null) { + String dungeonId = siren.getStringProperty("@"); + if(dungeonId != null) { + // Index metablocks in this dungeon + for(MetaBlock mb : zone.getMetaBlocks()) { + if(mb != siren && dungeonId.equals(mb.getStringProperty("@"))) { + if(mb.getItem().hasId(speakerId)) { + speakers.add(mb); + } else if(doorIds.contains(mb.getItem().getId())) { + doors.add(mb); + } + } + } + if(speakers.size() >= 3) { + initialNumSpeakers = speakers.size(); + // Success + return; + } else { + initiator.notify("You can't raid this dungeon anymore because it has been tampered with."); + } + } else { + initiator.notify("Siren doesn't appear to be inside a dungeon."); + } + } else { + initiator.notify("Siren metadata not found"); + } + + // Failure + finish(); + } + + @Override + public void tick(float deltaTime) { + super.tick(deltaTime); + + } + + @Override + public void onInteract(Player player) { + addParticipant(player); + + // Increase potency if the minigame hasn't started yet + if(!hasStarted()) { + if(!potencyBumps.contains(player.getDocumentId()) || player.isGodMode()) { + zone.notifyPlayers(String.format("%s increased the group dungeon's potency level to %s!", player.getName(), ++potencyLevel), NotificationType.PEER_ACCOMPLISHMENT); + } else { + List whistleCounts = whistles.stream().map(item -> player.getInventory().getQuantity(item)).collect(Collectors.toList()); + if(!whistleCounts.stream().anyMatch(x -> x != 0)) { + player.notify("You need whistles to increase the chaos level more. Maybe invite more players to this dungeon instead."); + return; + } + Dialog dialog = new Dialog().setTitle("Group Dungeon Chaos Level"); + dialog.addSection(new DialogSection().setText("You can increase this dungeon's chaos level even more using whistles.")); + for(int i = 0; i < whistleCounts.size(); i++) { + if(whistleCounts.get(i) > 0) { + dialog.addSection(TradeSession.Dialogs.createQuantitySelector(whistles.get(i).getId(), Math.min(5, whistleCounts.get(i)), 1, true).setTitle("Use how many " + whistles.get(i).getFancyTitle() + "? Increases level by " + whistles.get(i).getPower() + ".")); + } + } + + player.showDialog(dialog, ans -> { + if(ans.length >= 1 && "cancel".equals(ans[0])) { + return; + } + int ansI = 0; + try { + for(DialogSection section : dialog.getSections()) { + if(section.getInput() != null) { + String itemId = section.getInput().getKey(); + if(ansI >= ans.length) { + player.notify("Bad input. Too few choices submitted."); + return; + } + Object val = ans[ansI++]; + if (!(val instanceof String)) { + player.notify("Bad input type."); + return; + } + int qty = Integer.parseInt((String)val); + Item item = ItemRegistry.getItem(itemId); + if(!player.getInventory().hasItem(item, qty)) { + player.notify("You don't have that many " + item.getFancyTitle() + " anymore."); + } + } + } + // Validation complete, now remove the whistles and increase the potency level + ansI = 0; + for(DialogSection section : dialog.getSections()) { + if(section.getInput() != null) { + String itemId = section.getInput().getKey(); + String val = (String)ans[ansI++]; + int qty = Integer.parseInt(val); + Item item = ItemRegistry.getItem(itemId); + player.getInventory().removeItem(item, qty, true); + potencyLevel += qty * item.getPower(); + } + } + zone.notifyPlayers(String.format("%s increased the group dungeon's potency level to %s!", player.getName(), potencyLevel), NotificationType.PEER_ACCOMPLISHMENT); + } catch (Exception e) { + player.notify("Error while parsing your input."); + e.printStackTrace(); + } + }); + } + } + } + + @Override + protected void onStart() { + zone.spawnEffect(x, y, "match start", 1); + zone.updateBlock(x, y, Layer.FRONT, sirenOpenId, 1); + potencyBumps.add(initiator.getDocumentId()); + potencyLevel++; + + // Notify all players in the zone + for(Player player : zone.getPlayers()) { + player.notifyProfile(String.format("%s is raiding a Group Dungeon at %s", initiator.getName(), zone.getReadableCoordinates(x, y)), String.format("Tap on and/or use whistles on its siren in the next %d seconds to build chaos!", config.getStartPeriod() / 1000)); + } + } + + @Override + protected void onFinish() { + // Kill all spawned entities + for(Npc entity : spawns) { + entity.setMinigame(null); + entity.setHealth(0.0F); + } + } + + protected void complete() { + finish(); + double luckMultiplier = Math.min(10.0, potencyLevel); + int baseLuck = Math.min(12, participants.size() * 4); + int position = 0; + + // Give out rewards + Item pandoraOpen = ItemRegistry.getItem(sirenOpenId); + String[] rewardLootCategories = pandoraOpen.getLootCategories(); + for(Participant participant : leaderboard) { + if(participant.isParticipating()) { + int luck = (int)(Math.max(1, baseLuck - position * 4) * luckMultiplier); + Player player = participant.getPlayer(); + + Loot loot = GameServer.getInstance().getLootManager().getRandomLoot(luck, zone.getBiome(), player.getInventory().getWardrobe(), rewardLootCategories); + + if(loot != null) { + player.awardLoot(loot, String.format("You won %s place!", ordinalizeNumber(position + 1))); + } else { + player.notify("Sorry, we couldn't find a suitable reward for you."); + } + } + + position++; + } + + // Broadcast leader's score + zone.notifyPlayers(String.format("Pandora has been contained! %s showed mastery with %s!", currentLeader.getPlayer().getName(), describeScore(currentLeader.getScore()))); + } + + protected boolean hasStarted() { + return System.currentTimeMillis() < startedAt + config.getStartPeriod(); + } + + protected int getCurrentRound() { + return initialNumSpeakers - speakers.size() + 1; + } + + protected void setDoorsOpen(boolean open) { + for(MetaBlock door : doors) { + Block block = zone.getBlock(door.getX(), door.getY()); + if(block != null) { + int wantedMod = open ? 1 : 0; + int currentMod = block.getFrontMod(); + if(wantedMod != currentMod) { + zone.updateBlockMod(door.getX(), door.getY(), Layer.FRONT, wantedMod); + zone.spawnEffect(door.getX() + block.getFrontItem().getBlockWidth() / 2.0f, door.getY(), "steam", 4); + } + } + } + } + + public void entityKilled(Entity entity, EntityAttack cause) { + spawns.remove(entity); + } + + public void entityAttacked(Entity entity, EntityAttack attack, float damage) { + // Do nothing if entity is not a wave enemy + if(!spawns.contains(entity)) { + return; + } + + Entity attacker = attack.getAttacker(); + + // Check if attacker is present + if(attacker == null || !attacker.isPlayer()) { + return; + }; + + Player player = (Player)attacker; + Participant participant = addParticipant(player); + participant.incrementScore(damage); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java new file mode 100644 index 00000000..e15d7ddc --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java @@ -0,0 +1,21 @@ +package brainwine.gameserver.minigame; + +import brainwine.gameserver.entity.EntityConfig; +import brainwine.gameserver.util.WeightedMap; + +import java.util.List; + +public class GroupDungeonConfig { + private long startPeriod = 120000; + private List> enemies; + + int chaosLevel = 1; + + public long getStartPeriod() { + return startPeriod; + } + + public List> getEnemies() { + return enemies; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/player/TradeSession.java b/gameserver/src/main/java/brainwine/gameserver/player/TradeSession.java index 57e9bbb0..9fdc0a14 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/TradeSession.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/TradeSession.java @@ -558,6 +558,10 @@ public static DialogSection createQuantitySelector(int maxQuantity) { } public static DialogSection createQuantitySelector(int maxQuantity, int unit) { + return createQuantitySelector("quantity", maxQuantity, unit, false); + } + + public static DialogSection createQuantitySelector(String key, int maxQuantity, int unit, boolean allowZero) { // Get quantity options that are available to the player List quantityOptions = ITEM_QUANTITY_OPTIONS.stream() .filter(quantity -> Integer.parseInt(quantity) * unit <= maxQuantity) From 1a36f7197e6554e9ca4c4aee275ae88a8606cc84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Sun, 29 Mar 2026 21:23:47 +0200 Subject: [PATCH 2/8] add kinda working group dungeon code --- .../java/brainwine/gameserver/GameServer.java | 2 + .../gameserver/item/ItemUseType.java | 1 + .../interactions/MinigameInteraction.java | 4 + .../gameserver/minigame/GroupDungeon.java | 248 ++++++++++++++---- .../minigame/GroupDungeonConfig.java | 158 ++++++++++- .../java/brainwine/gameserver/zone/Zone.java | 8 + 6 files changed, 363 insertions(+), 58 deletions(-) diff --git a/gameserver/src/main/java/brainwine/gameserver/GameServer.java b/gameserver/src/main/java/brainwine/gameserver/GameServer.java index e8b1ffe0..9264c02c 100644 --- a/gameserver/src/main/java/brainwine/gameserver/GameServer.java +++ b/gameserver/src/main/java/brainwine/gameserver/GameServer.java @@ -7,6 +7,7 @@ import brainwine.gameserver.androidshop.AndroidShop; import brainwine.gameserver.androidshop.AndroidShopPerIpHistory; +import brainwine.gameserver.minigame.GroupDungeon; import brainwine.gameserver.scrapmarket.ScrapMarket; import brainwine.gameserver.anticheat.AnticheatManager; import brainwine.gameserver.chat.ProfanityManager; @@ -73,6 +74,7 @@ public GameServer() { EntityManager.loadEntitySpawns(); GrowthManager.loadGrowthData(); Pandora.loadConfig(); + GroupDungeon.loadConfig(); Quests.loadQuests(); dailyRewardManager = new DailyRewardManager(); AndroidShop.getInstance().loadShopData(); diff --git a/gameserver/src/main/java/brainwine/gameserver/item/ItemUseType.java b/gameserver/src/main/java/brainwine/gameserver/item/ItemUseType.java index 55fb797e..efc48a9a 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/ItemUseType.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/ItemUseType.java @@ -69,6 +69,7 @@ public enum ItemUseType { PET, PILE(new PileInteraction()), PLENTY, + POTENCY_BUMP, PROTECTED, PUBLIC, REVENANT_DISH, diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/MinigameInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/MinigameInteraction.java index 6691fcd2..e5c70fcf 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/interactions/MinigameInteraction.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/MinigameInteraction.java @@ -5,6 +5,7 @@ import brainwine.gameserver.entity.Entity; import brainwine.gameserver.item.Item; import brainwine.gameserver.item.Layer; +import brainwine.gameserver.minigame.GroupDungeon; import brainwine.gameserver.minigame.Minigame; import brainwine.gameserver.minigame.Pandora; import brainwine.gameserver.player.Player; @@ -82,6 +83,9 @@ public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item i case "pandora": minigame = new Pandora(zone, player, x, y); break; + case "group-dungeon": + minigame = new GroupDungeon(zone, player, x, y); + break; default: player.notify(String.format("Sorry, minigame type '%s' is not supported.", type)); return; diff --git a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java index ef17fd25..35677543 100644 --- a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java +++ b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java @@ -1,5 +1,6 @@ package brainwine.gameserver.minigame; +import brainwine.gameserver.Fake; import brainwine.gameserver.GameServer; import brainwine.gameserver.dialog.Dialog; import brainwine.gameserver.dialog.DialogSection; @@ -8,75 +9,143 @@ import brainwine.gameserver.entity.npc.Npc; import brainwine.gameserver.item.Item; import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.item.ItemUseType; import brainwine.gameserver.item.Layer; import brainwine.gameserver.loot.Loot; import brainwine.gameserver.player.NotificationType; import brainwine.gameserver.player.Player; import brainwine.gameserver.player.TradeSession; +import brainwine.gameserver.resource.ResourceFinder; +import brainwine.gameserver.util.WeightedMap; import brainwine.gameserver.zone.Block; import brainwine.gameserver.zone.MetaBlock; import brainwine.gameserver.zone.Zone; +import brainwine.shared.JsonHelper; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import java.util.ArrayList; -import java.util.Arrays; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import static brainwine.shared.LogMarkers.SERVER_MARKER; + public class GroupDungeon extends Minigame { - GroupDungeonConfig config = new GroupDungeonConfig(); - private final String sirenOpenId = "mechanical/siren-open"; - private final String speakerId = "mechanical/speaker"; - private final Set doorIds = new HashSet<>(Arrays.asList("mechanical/door-beefy-closed-iron", "mechanical/door-beefy-closed-copper")); - private final List whistles = Arrays.asList(ItemRegistry.getItem("accessories/whistle-onyx"), ItemRegistry.getItem("accessories/whistle-diamond"), ItemRegistry.getItem("accessories/whistle-brass")); + private static final Logger logger = LogManager.getLogger(); + private static GroupDungeonConfig config = new GroupDungeonConfig(); + private static Set allSpeakerItems = new HashSet<>(config.getAllSpeakerItems()); + private static Set allDoorItems = new HashSet<>(config.getAllDoorItems()); + private static final String sirenOpenId = "mechanical/siren-open"; - boolean started = false; int potencyLevel = 0; Set potencyBumps = new HashSet<>(); List speakers = new ArrayList<>(); List doors = new ArrayList<>(); int initialNumSpeakers = 0; + int enemiesLeftInWave = 0; + int enemyInterval; + long lastSpawnedAt; private final List spawns = new ArrayList<>(); + boolean raidStarted = false; + + public static void loadConfig() { + logger.info(SERVER_MARKER, "Loading group dungeon configuration ..."); + + try { + GroupDungeon.config = JsonHelper.readValue(ResourceFinder.getResourceUrl("group-dungeon.json"), GroupDungeonConfig.class); + GroupDungeon.allSpeakerItems = new HashSet<>(config.getAllSpeakerItems()); + GroupDungeon.allDoorItems = new HashSet<>(config.getAllDoorItems()); + } catch(Exception e) { + logger.error(SERVER_MARKER, "Failed to load group dungeon config", e); + } + } + + public static GroupDungeonConfig getConfig() { + return config; + } public GroupDungeon(Zone zone, Player initiator, int x, int y) { super(zone, initiator, x, y); - MetaBlock siren = zone.getMetaBlock(x, y); - if(siren != null) { - String dungeonId = siren.getStringProperty("@"); - if(dungeonId != null) { - // Index metablocks in this dungeon - for(MetaBlock mb : zone.getMetaBlocks()) { - if(mb != siren && dungeonId.equals(mb.getStringProperty("@"))) { - if(mb.getItem().hasId(speakerId)) { - speakers.add(mb); - } else if(doorIds.contains(mb.getItem().getId())) { - doors.add(mb); - } + } + + @Override + public void tick(float deltaTime) { + super.tick(deltaTime); + long now = System.currentTimeMillis(); + + if(!raidStarted) { + if(now >= startedAt + config.getGracePeriod()) { + raidStarted = true; + enemiesLeftInWave = getTotalEnemiesInWave(getCurrentWave()); + setDoorsOpen(false); + for(Player player: zone.getPlayers()) { + if(participants.containsKey(player)) { + player.notify("Oh no, the doors are shut! Now your only way out is to end these pesky brains."); + } else { + player.notify(String.format("The group dungeon at %s has locked down. You can't help raid it anymore.", zone.getReadableCoordinates(x, y))); } } - if(speakers.size() >= 3) { - initialNumSpeakers = speakers.size(); - // Success - return; - } else { - initiator.notify("You can't raid this dungeon anymore because it has been tampered with."); - } + } + return; + } + + if(enemiesLeftInWave > 0 && now > enemyInterval + lastSpawnedAt) { + enemyInterval = (int)(500 + Math.random() * 2000); + lastSpawnedAt = System.currentTimeMillis(); + // If there are still enemies left to spawn this wave, and it is time to spawn another one + int currentWave = getCurrentWave(); + // Pick the enemy table that is only as hard as the current wave or easier + WeightedMap currentEnemyTable = config.getEnemies().get(config.getEnemies().keySet().stream().filter(wave -> currentWave >= wave).max(Integer::compareTo).orElse(1)); + MetaBlock speaker = Fake.pickFromList(speakers); + String entityType = currentEnemyTable.next(); + Npc npc = zone.spawnEntity(entityType, speaker.getX(), speaker.getY()); + if(npc == null) { + logger.error("Couldn't spawn entity {}!", entityType); } else { - initiator.notify("Siren doesn't appear to be inside a dungeon."); + npc.setMinigame(this); + spawns.add(npc); + enemiesLeftInWave--; } - } else { - initiator.notify("Siren metadata not found"); } - // Failure - finish(); - } + if(spawns.isEmpty()) { + // Remove stale speakers + for(int i = 0; i < speakers.size(); i++) { + MetaBlock current = zone.getMetaBlock(speakers.get(i).getX(), speakers.get(i).getY()); + if(current == null || !allSpeakerItems.contains(current.getItem())) { + speakers.remove(i); + i--; + } + } - @Override - public void tick(float deltaTime) { - super.tick(deltaTime); + if(getCurrentWave() >= initialNumSpeakers) { + // If all speakers have been destroyed, complete + complete(); + return; + } + + if(enemiesLeftInWave == 0) { + // If the wave is over, set up for the next wave + if(getCurrentWave() < initialNumSpeakers) { + notifyParticipants(String.format("Wave %d is starting!", getCurrentWave())); + } + MetaBlock speakerToRemove = Fake.pickFromList(speakers); + speakers.remove(speakerToRemove); + zone.updateBlock(speakerToRemove.getX(), speakerToRemove.getY(), Layer.FRONT, Item.AIR); + zone.spawnEffect(speakerToRemove.getX(), speakerToRemove.getY(), "bomb-large", 2); + enemiesLeftInWave = getTotalEnemiesInWave(getCurrentWave()); + lastSpawnedAt = System.currentTimeMillis(); + enemyInterval = (int)(2000 + Math.random() * 8000); + } + } + } + private int getTotalEnemiesInWave(int wave) { + return (int)(initialNumSpeakers + (potencyLevel * initialNumSpeakers * wave * wave) / 10.0); } @Override @@ -84,10 +153,27 @@ public void onInteract(Player player) { addParticipant(player); // Increase potency if the minigame hasn't started yet - if(!hasStarted()) { + if(!raidStarted) { if(!potencyBumps.contains(player.getDocumentId()) || player.isGodMode()) { zone.notifyPlayers(String.format("%s increased the group dungeon's potency level to %s!", player.getName(), ++potencyLevel), NotificationType.PEER_ACCOMPLISHMENT); } else { + Block blockInteractingWith = zone.getBlock(x, y); + Item interactingWith = blockInteractingWith != null ? blockInteractingWith.getFrontItem() : Item.AIR; + List whistles = new ArrayList<>(); + try { + if(interactingWith.getUse(ItemUseType.POTENCY_BUMP) instanceof Map) { + ((Map) interactingWith.getUse(ItemUseType.POTENCY_BUMP)).keySet().stream() + .map(ItemRegistry::getItem) + .filter(item -> !item.isAir()) + .forEach(whistles::add); + } + } catch (Exception e) { + logger.error("Error while listing the potency bump items", e); + } + if(whistles.isEmpty()) { + player.notify("Don't know what items you can use to bump potency.", NotificationType.SYSTEM); + return; + } List whistleCounts = whistles.stream().map(item -> player.getInventory().getQuantity(item)).collect(Collectors.toList()); if(!whistleCounts.stream().anyMatch(x -> x != 0)) { player.notify("You need whistles to increase the chaos level more. Maybe invite more players to this dungeon instead."); @@ -115,7 +201,7 @@ public void onInteract(Player player) { return; } Object val = ans[ansI++]; - if (!(val instanceof String)) { + if(!(val instanceof String)) { player.notify("Bad input type."); return; } @@ -150,6 +236,49 @@ public void onInteract(Player player) { @Override protected void onStart() { + // Validations that don't interact with the world. + if(config.getEnemies().isEmpty()) { + notifyParticipants("Don't know what to spawn! Ending the raid now."); + finish(); + return; + } + + // Validations that do interact with the world + MetaBlock siren = zone.getMetaBlock(x, y); + if(siren == null) { + initiator.notify("Siren metadata not found"); + finish(); + return; + } + + String dungeonId = siren.getStringProperty("@"); + if(dungeonId == null) { + initiator.notify("Siren doesn't appear to be inside a dungeon."); + finish(); + return; + } + + // Index meta-blocks in this dungeon + for(MetaBlock mb : zone.getMetaBlocks()) { + if(mb != siren && dungeonId.equals(mb.getStringProperty("@"))) { + if(allSpeakerItems.contains(mb.getItem())) { + speakers.add(mb); + } else if(allDoorItems.contains(mb.getItem())) { + doors.add(mb); + } + } + } + + if(speakers.size() < 3) { + initiator.notify("You can't raid this dungeon anymore because it has been tampered with."); + finish(); + return; + } + + initialNumSpeakers = speakers.size(); + zone.updateBlock(x, y, Layer.FRONT, ItemRegistry.getItem(sirenOpenId)); + + // Everything went well zone.spawnEffect(x, y, "match start", 1); zone.updateBlock(x, y, Layer.FRONT, sirenOpenId, 1); potencyBumps.add(initiator.getDocumentId()); @@ -157,7 +286,7 @@ protected void onStart() { // Notify all players in the zone for(Player player : zone.getPlayers()) { - player.notifyProfile(String.format("%s is raiding a Group Dungeon at %s", initiator.getName(), zone.getReadableCoordinates(x, y)), String.format("Tap on and/or use whistles on its siren in the next %d seconds to build chaos!", config.getStartPeriod() / 1000)); + player.notifyProfile(String.format("%s is raiding a Group Dungeon at %s! Join them before the gates close.", initiator.getName(), zone.getReadableCoordinates(x, y)), String.format("Tap on and/or use whistles on its siren in the next %d seconds to build chaos!", config.getGracePeriod() / 1000)); } } @@ -197,26 +326,45 @@ protected void complete() { } // Broadcast leader's score - zone.notifyPlayers(String.format("Pandora has been contained! %s showed mastery with %s!", currentLeader.getPlayer().getName(), describeScore(currentLeader.getScore()))); - } - - protected boolean hasStarted() { - return System.currentTimeMillis() < startedAt + config.getStartPeriod(); + zone.notifyPlayers(String.format("Dungeon has been raided! %s showed mastery with %s!", currentLeader.getPlayer().getName(), describeScore(currentLeader.getScore()))); } - protected int getCurrentRound() { + protected int getCurrentWave() { return initialNumSpeakers - speakers.size() + 1; } protected void setDoorsOpen(boolean open) { - for(MetaBlock door : doors) { + Map toLookFor = new HashMap<>(); + for(GroupDungeonConfig.DoorState doorState: config.getDoors()) { + if(open) { + // closed to open + toLookFor.put(doorState.getClosed(), doorState.getOpen()); + } else { + // open to closed + toLookFor.put(doorState.getOpen(), doorState.getClosed()); + } + } + for(int i = 0; i < doors.size(); i++) { + MetaBlock door = doors.get(i); Block block = zone.getBlock(door.getX(), door.getY()); if(block != null) { - int wantedMod = open ? 1 : 0; - int currentMod = block.getFrontMod(); - if(wantedMod != currentMod) { - zone.updateBlockMod(door.getX(), door.getY(), Layer.FRONT, wantedMod); - zone.spawnEffect(door.getX() + block.getFrontItem().getBlockWidth() / 2.0f, door.getY(), "steam", 4); + GroupDungeonConfig.BlockState wantedState = toLookFor.get( + new GroupDungeonConfig.BlockState( + block.getFrontItem(), + block.getFrontMod(), + door.getMetadata() + ) + ); + if(wantedState != null) { + int currentMod = block.getFrontMod(); + if(wantedState.getItem() != block.getFrontItem() || wantedState.getMod() != currentMod) { + Map newMetadata = new HashMap<>(door.getMetadata()); + if(wantedState.getMetadata() != null) { + newMetadata.putAll(wantedState.getMetadata()); + } + zone.updateBlock(door.getX(), door.getY(), Layer.FRONT, wantedState.getItem(), wantedState.getMod(), null, newMetadata); + zone.spawnEffect(door.getX() + block.getFrontItem().getBlockWidth() / 2.0f - 0.5f, door.getY(), "steam", 4); + } } } } diff --git a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java index e15d7ddc..7c62e3e7 100644 --- a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java +++ b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java @@ -1,21 +1,163 @@ package brainwine.gameserver.minigame; -import brainwine.gameserver.entity.EntityConfig; +import brainwine.gameserver.entity.EntityRegistry; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.util.MapHelper; import brainwine.gameserver.util.WeightedMap; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSetter; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; -import java.util.List; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; public class GroupDungeonConfig { - private long startPeriod = 120000; - private List> enemies; + private static final Logger logger = LogManager.getLogger(); - int chaosLevel = 1; + private long gracePeriod = 120000; + private List doors = Arrays.asList( + new DoorState( + new BlockState("mechanical/door-beefy", 1, null), + new BlockState("mechanical/door-beefy", 0, null) + ) + ); + private List speakers = Arrays.asList( + new BlockState("mechanical/speaker", 0, null) + ); - public long getStartPeriod() { - return startPeriod; + private Map> enemies = MapHelper.map(1, new WeightedMap<>(MapHelper.map( + String.class, Double.class, + "brains/small-minion", 15.0, + "brains/medium-minion", 2.0, + "brains/medium-dire-minion", 1.0 + ))); + + public long getGracePeriod() { + return gracePeriod; + } + + @JsonSetter + public void setEnemies(Map> enemies) { + if(enemies.isEmpty()) { + throw new IllegalArgumentException("No enemy tables for the group dungeon are configured."); + } + + Map> newEnemies = new HashMap<>(); + + for(Map.Entry> waveEntry : enemies.entrySet()) { + if(waveEntry.getKey() <= 0) continue; + Map original = enemies.get(waveEntry.getKey()); + Map filtered = original.entrySet().stream() + .filter(ent -> EntityRegistry.getEntityConfig(ent.getKey()) != null + && ent.getValue() != null && ent.getValue() > 0.0 + ).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + if(filtered.isEmpty()) { + throw new IllegalArgumentException(String.format("None of the entity IDs in the enemy table for the group dungeon wave %d are valid.", waveEntry.getKey())); + } + newEnemies.put(waveEntry.getKey(), new WeightedMap(filtered)); + } + + if(!newEnemies.isEmpty()) { + // Make sure the first wave config is present + if(!this.enemies.containsKey(1)) { + int minKey = newEnemies.keySet().stream().min(Integer::compareTo).orElse(1); + newEnemies.put(1, newEnemies.get(minKey)); + } + this.enemies = newEnemies; + } else { + logger.warn("None of the group dungeon waves are positive - not configuring wave enemies."); + } } - public List> getEnemies() { + public Map> getEnemies() { return enemies; } + + public List getDoors() { + return doors; + } + + public List getSpeakers() { + return speakers; + } + + @JsonIgnore + public List getAllDoorItems() { + return doors.stream() + .flatMap(door -> Stream.of(door.getOpen().getItem(), door.getClosed().getItem())) + .collect(Collectors.toList()); + } + + @JsonIgnore + public List getAllSpeakerItems() { + return speakers.stream().map(BlockState::getItem).collect(Collectors.toList()); + } + + public static class BlockState { + public Item item; + public int mod; + public Map metadata; + + public BlockState(@JsonSetter String itemId, @JsonSetter int mod, @JsonSetter Map metadata) { + this(ItemRegistry.getItem(itemId), mod, metadata); + } + + public BlockState(Item item, int mod, Map metadata) { + if(item == null) { + throw new IllegalArgumentException("Item null not found."); + } + + if(item.isAir()) { + throw new IllegalArgumentException("Item " + item.getId() + " not found."); + } + + this.item = item; + this.mod = mod; + this.metadata = metadata; + } + + public Item getItem() { + return item; + } + + public int getMod() { + return mod; + } + + public Map getMetadata() { + return metadata; + } + + @Override + public boolean equals(Object o) { + if(!(o instanceof BlockState that)) return false; + return mod == that.mod && Objects.equals(item, that.item); + } + + @Override + public int hashCode() { + return Objects.hash(item, mod); + } + } + + public static class DoorState { + private BlockState open; + private BlockState closed; + + public DoorState(@JsonSetter BlockState open, @JsonSetter BlockState closed) { + this.open = open; + this.closed = closed; + } + + public BlockState getOpen() { + return open; + } + + public BlockState getClosed() { + return closed; + } + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java index c24f2b7c..037a103e 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java @@ -22,6 +22,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import brainwine.gameserver.minigame.GroupDungeon; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; @@ -965,6 +966,9 @@ public void placePrefab(Prefab prefab, int x, int y, Random random, boolean mirr } }); + Set allGroupDungeonItems = new HashSet<>(); + allGroupDungeonItems.addAll(GroupDungeon.getConfig().getAllDoorItems()); + allGroupDungeonItems.addAll(GroupDungeon.getConfig().getAllSpeakerItems()); boolean[] ruinMask = new boolean[width]; for(int j = 0; j < height; j++) { for(int i = 0; i < width; i++) { @@ -1072,6 +1076,10 @@ public void placePrefab(Prefab prefab, int x, int y, Random random, boolean mirr entityManager.updateRevenantDish(x, y, true); } + if(dungeonId != null && (frontItem.getUse(ItemUseType.MINIGAME) instanceof Map && "group-dungeon".equals(((Map)frontItem.getUse(ItemUseType.MINIGAME)).get("type")) || allGroupDungeonItems.contains(frontItem))) { + metadata.put("@", dungeonId); + } + if(dungeonId != null && frontItem.hasId("mechanical/spawner-brain")) { metadata.put("@", dungeonId); // dungeonType = DungeonType.morePrior(dungeonType, DungeonType.EVOKER); From 9226cea2c517bb18f41564eecb8c6cb7145e1f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Sun, 29 Mar 2026 22:16:12 +0200 Subject: [PATCH 3/8] fix last wave not spawning --- .../gameserver/minigame/GroupDungeon.java | 25 +++++++++++-------- .../minigame/GroupDungeonConfig.java | 3 ++- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java index 35677543..59c614c9 100644 --- a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java +++ b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java @@ -76,13 +76,16 @@ public GroupDungeon(Zone zone, Player initiator, int x, int y) { public void tick(float deltaTime) { super.tick(deltaTime); long now = System.currentTimeMillis(); + final int currentWave = getCurrentWave(); if(!raidStarted) { if(now >= startedAt + config.getGracePeriod()) { raidStarted = true; - enemiesLeftInWave = getTotalEnemiesInWave(getCurrentWave()); + enemiesLeftInWave = getTotalEnemiesInWave(currentWave); setDoorsOpen(false); - for(Player player: zone.getPlayers()) { + lastSpawnedAt = System.currentTimeMillis(); + enemyInterval = (int) (500 + Math.random() * 2000); + for(Player player : zone.getPlayers()) { if(participants.containsKey(player)) { player.notify("Oh no, the doors are shut! Now your only way out is to end these pesky brains."); } else { @@ -94,10 +97,9 @@ public void tick(float deltaTime) { } if(enemiesLeftInWave > 0 && now > enemyInterval + lastSpawnedAt) { - enemyInterval = (int)(500 + Math.random() * 2000); - lastSpawnedAt = System.currentTimeMillis(); // If there are still enemies left to spawn this wave, and it is time to spawn another one - int currentWave = getCurrentWave(); + lastSpawnedAt = System.currentTimeMillis(); + enemyInterval = (int)(500 + Math.random() * 2000); // Pick the enemy table that is only as hard as the current wave or easier WeightedMap currentEnemyTable = config.getEnemies().get(config.getEnemies().keySet().stream().filter(wave -> currentWave >= wave).max(Integer::compareTo).orElse(1)); MetaBlock speaker = Fake.pickFromList(speakers); @@ -122,7 +124,7 @@ public void tick(float deltaTime) { } } - if(getCurrentWave() >= initialNumSpeakers) { + if(currentWave >= initialNumSpeakers + 1) { // If all speakers have been destroyed, complete complete(); return; @@ -130,14 +132,14 @@ public void tick(float deltaTime) { if(enemiesLeftInWave == 0) { // If the wave is over, set up for the next wave - if(getCurrentWave() < initialNumSpeakers) { - notifyParticipants(String.format("Wave %d is starting!", getCurrentWave())); + if(currentWave < initialNumSpeakers) { + notifyParticipants(String.format("Wave %d is starting!", currentWave + 1)); } MetaBlock speakerToRemove = Fake.pickFromList(speakers); speakers.remove(speakerToRemove); zone.updateBlock(speakerToRemove.getX(), speakerToRemove.getY(), Layer.FRONT, Item.AIR); zone.spawnEffect(speakerToRemove.getX(), speakerToRemove.getY(), "bomb-large", 2); - enemiesLeftInWave = getTotalEnemiesInWave(getCurrentWave()); + enemiesLeftInWave = getTotalEnemiesInWave(currentWave + 1); lastSpawnedAt = System.currentTimeMillis(); enemyInterval = (int)(2000 + Math.random() * 8000); } @@ -292,6 +294,7 @@ protected void onStart() { @Override protected void onFinish() { + setDoorsOpen(true); // Kill all spawned entities for(Npc entity : spawns) { entity.setMinigame(null); @@ -306,8 +309,8 @@ protected void complete() { int position = 0; // Give out rewards - Item pandoraOpen = ItemRegistry.getItem(sirenOpenId); - String[] rewardLootCategories = pandoraOpen.getLootCategories(); + Item sirenOpen = ItemRegistry.getItem(sirenOpenId); + String[] rewardLootCategories = sirenOpen.getLootCategories(); for(Participant participant : leaderboard) { if(participant.isParticipating()) { int luck = (int)(Math.max(1, baseLuck - position * 4) * luckMultiplier); diff --git a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java index 7c62e3e7..c27d885c 100644 --- a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java +++ b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java @@ -133,7 +133,8 @@ public Map getMetadata() { @Override public boolean equals(Object o) { - if(!(o instanceof BlockState that)) return false; + if(!(o instanceof BlockState)) return false; + BlockState that = (BlockState)o; return mod == that.mod && Objects.equals(item, that.item); } From 667aa3ddf1c8d3968b97a516cdf6f96b1292db76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Sun, 29 Mar 2026 22:47:52 +0200 Subject: [PATCH 4/8] add start conditions for minigames --- .../item/interactions/MinigameInteraction.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/MinigameInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/MinigameInteraction.java index e5c70fcf..3b5a5924 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/interactions/MinigameInteraction.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/MinigameInteraction.java @@ -58,6 +58,18 @@ public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item i player.notify("Sorry, custom minigames are not supported yet."); return; } + + String startCondition = MapHelper.getString(configMap, "start_condition"); + if(startCondition != null) { + switch(startCondition) { + case "dungeon-raided": + case "dungeon-is-raided": + if(metaBlock != null && zone.isDungeonIntact(metaBlock.getStringProperty("@"))) { + player.notify("You must destroy all the enemy protectors first."); + return; + } + } + } Map startDialog = MapHelper.getMap(configMap, "start_dialog"); From 5168382137cc8469c4dce842e063732b3859754d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Sun, 29 Mar 2026 23:58:01 +0200 Subject: [PATCH 5/8] limit group dungeon potency bumps --- .../gameserver/minigame/GroupDungeon.java | 50 +++++++++++++------ .../minigame/GroupDungeonConfig.java | 10 ++++ .../gameserver/player/TradeSession.java | 5 +- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java index 59c614c9..8c15c0ef 100644 --- a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java +++ b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java @@ -42,7 +42,7 @@ public class GroupDungeon extends Minigame { private static final String sirenOpenId = "mechanical/siren-open"; int potencyLevel = 0; - Set potencyBumps = new HashSet<>(); + Map potencyBumps = new HashMap<>(); List speakers = new ArrayList<>(); List doors = new ArrayList<>(); int initialNumSpeakers = 0; @@ -156,15 +156,31 @@ public void onInteract(Player player) { // Increase potency if the minigame hasn't started yet if(!raidStarted) { - if(!potencyBumps.contains(player.getDocumentId()) || player.isGodMode()) { + boolean totalBumpsLimited = config.getMaxTotalBumps() > 0; + int totalBumpsLeft = config.getMaxTotalBumps() - potencyLevel; + if(totalBumpsLimited && totalBumpsLeft <= 0) { + player.notify("Sorry, no more players can participate in this raid."); + return; + } + if(!potencyBumps.containsKey(player.getDocumentId()) || player.isGodMode()) { + potencyBumps.put(player.getDocumentId(), 1); zone.notifyPlayers(String.format("%s increased the group dungeon's potency level to %s!", player.getName(), ++potencyLevel), NotificationType.PEER_ACCOMPLISHMENT); } else { + boolean playerBumpsLimited = config.getMaxPlayerBumps() > 0; + int playerBumpsLeft = config.getMaxPlayerBumps() - potencyBumps.getOrDefault(player.getDocumentId(), 0); + boolean bumpsLimited = totalBumpsLimited || playerBumpsLimited; + int bumpsLeft = totalBumpsLimited && playerBumpsLimited ? Math.min(totalBumpsLeft, playerBumpsLeft) : totalBumpsLimited ? totalBumpsLeft : playerBumpsLeft; + if(bumpsLimited && bumpsLeft <= 0) { + player.notify("Sorry, you can't make this raid harder anymore. Maybe ask more players to join?"); + return; + } + Block blockInteractingWith = zone.getBlock(x, y); Item interactingWith = blockInteractingWith != null ? blockInteractingWith.getFrontItem() : Item.AIR; List whistles = new ArrayList<>(); try { - if(interactingWith.getUse(ItemUseType.POTENCY_BUMP) instanceof Map) { - ((Map) interactingWith.getUse(ItemUseType.POTENCY_BUMP)).keySet().stream() + if(interactingWith.getUse(ItemUseType.POTENCY_BUMP) instanceof List) { + ((List) interactingWith.getUse(ItemUseType.POTENCY_BUMP)).stream() .map(ItemRegistry::getItem) .filter(item -> !item.isAir()) .forEach(whistles::add); @@ -185,7 +201,9 @@ public void onInteract(Player player) { dialog.addSection(new DialogSection().setText("You can increase this dungeon's chaos level even more using whistles.")); for(int i = 0; i < whistleCounts.size(); i++) { if(whistleCounts.get(i) > 0) { - dialog.addSection(TradeSession.Dialogs.createQuantitySelector(whistles.get(i).getId(), Math.min(5, whistleCounts.get(i)), 1, true).setTitle("Use how many " + whistles.get(i).getFancyTitle() + "? Increases level by " + whistles.get(i).getPower() + ".")); + Item whistle = whistles.get(i); + int allowed = Math.max(1, bumpsLeft / (int)Math.round(whistle.getPower())); + dialog.addSection(TradeSession.Dialogs.createQuantitySelector(whistle.getId(), allowed, 1, true).setTitle("Use how many " + whistle.getFancyTitle() + "? Increases level by " + whistle.getPower() + ".")); } } @@ -194,6 +212,8 @@ public void onInteract(Player player) { return; } int ansI = 0; + int bumps = 0; + Map used = new HashMap<>(); try { for(DialogSection section : dialog.getSections()) { if(section.getInput() != null) { @@ -211,20 +231,18 @@ public void onInteract(Player player) { Item item = ItemRegistry.getItem(itemId); if(!player.getInventory().hasItem(item, qty)) { player.notify("You don't have that many " + item.getFancyTitle() + " anymore."); + return; } + bumps = Math.min(bumpsLeft, qty * (int)Math.round(item.getPower())); + used.put(item, Math.max(0, Math.min(qty, (bumpsLeft - bumps) / (int)Math.round(item.getPower())))); } + if(bumps >= bumpsLeft) break; } // Validation complete, now remove the whistles and increase the potency level - ansI = 0; - for(DialogSection section : dialog.getSections()) { - if(section.getInput() != null) { - String itemId = section.getInput().getKey(); - String val = (String)ans[ansI++]; - int qty = Integer.parseInt(val); - Item item = ItemRegistry.getItem(itemId); - player.getInventory().removeItem(item, qty, true); - potencyLevel += qty * item.getPower(); - } + potencyLevel += bumps; + potencyBumps.put(player.getDocumentId(), potencyBumps.getOrDefault(player.getDocumentId(), 0) + 1); + for(Map.Entry usedItem : used.entrySet()) { + player.getInventory().removeItem(usedItem.getKey(), usedItem.getValue(), true); } zone.notifyPlayers(String.format("%s increased the group dungeon's potency level to %s!", player.getName(), potencyLevel), NotificationType.PEER_ACCOMPLISHMENT); } catch (Exception e) { @@ -283,7 +301,7 @@ protected void onStart() { // Everything went well zone.spawnEffect(x, y, "match start", 1); zone.updateBlock(x, y, Layer.FRONT, sirenOpenId, 1); - potencyBumps.add(initiator.getDocumentId()); + potencyBumps.put(initiator.getDocumentId(), 1); potencyLevel++; // Notify all players in the zone diff --git a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java index c27d885c..e482624e 100644 --- a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java +++ b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java @@ -18,6 +18,8 @@ public class GroupDungeonConfig { private static final Logger logger = LogManager.getLogger(); private long gracePeriod = 120000; + private int maxPlayerBumps = 4; + private int maxTotalBumps = 0; private List doors = Arrays.asList( new DoorState( new BlockState("mechanical/door-beefy", 1, null), @@ -39,6 +41,14 @@ public long getGracePeriod() { return gracePeriod; } + public int getMaxPlayerBumps() { + return maxPlayerBumps; + } + + public int getMaxTotalBumps() { + return maxTotalBumps; + } + @JsonSetter public void setEnemies(Map> enemies) { if(enemies.isEmpty()) { diff --git a/gameserver/src/main/java/brainwine/gameserver/player/TradeSession.java b/gameserver/src/main/java/brainwine/gameserver/player/TradeSession.java index 9fdc0a14..1e850f80 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/TradeSession.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/TradeSession.java @@ -567,11 +567,14 @@ public static DialogSection createQuantitySelector(String key, int maxQuantity, .filter(quantity -> Integer.parseInt(quantity) * unit <= maxQuantity) .map(quantity -> Integer.toString(Integer.parseInt(quantity) * unit)) .collect(Collectors.toList()); + if(allowZero) { + quantityOptions.add(0, "0"); + } return new DialogSection() .setInput(new DialogSelectInput() .setOptions(quantityOptions) - .setKey("quantity")); + .setKey(key != null ? key : "quantity")); } public static DialogSection createQuantitySelector(Player offerer, Item item) { From b5cdd65dba80ef1f657f8540e581f163775a5254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 30 Mar 2026 00:12:51 +0200 Subject: [PATCH 6/8] destroy the siren once the raid is over --- .../java/brainwine/gameserver/minigame/GroupDungeon.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java index 8c15c0ef..d891a966 100644 --- a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java +++ b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java @@ -136,9 +136,10 @@ public void tick(float deltaTime) { notifyParticipants(String.format("Wave %d is starting!", currentWave + 1)); } MetaBlock speakerToRemove = Fake.pickFromList(speakers); + Item speakerItem = speakerToRemove.getItem(); speakers.remove(speakerToRemove); zone.updateBlock(speakerToRemove.getX(), speakerToRemove.getY(), Layer.FRONT, Item.AIR); - zone.spawnEffect(speakerToRemove.getX(), speakerToRemove.getY(), "bomb-large", 2); + zone.spawnEffect(speakerToRemove.getX() + speakerItem.getBlockWidth() / 2.0f - 0.5f, speakerToRemove.getY() - speakerItem.getBlockHeight() / 2.0f + 0.5f, "bomb-electric", 1); enemiesLeftInWave = getTotalEnemiesInWave(currentWave + 1); lastSpawnedAt = System.currentTimeMillis(); enemyInterval = (int)(2000 + Math.random() * 8000); @@ -247,7 +248,7 @@ public void onInteract(Player player) { zone.notifyPlayers(String.format("%s increased the group dungeon's potency level to %s!", player.getName(), potencyLevel), NotificationType.PEER_ACCOMPLISHMENT); } catch (Exception e) { player.notify("Error while parsing your input."); - e.printStackTrace(); + logger.error("Error while handling potency bump dialog answers", e); } }); } @@ -348,6 +349,10 @@ protected void complete() { // Broadcast leader's score zone.notifyPlayers(String.format("Dungeon has been raided! %s showed mastery with %s!", currentLeader.getPlayer().getName(), describeScore(currentLeader.getScore()))); + + // Explode the siren + zone.updateBlock(x, y, Layer.FRONT, Item.AIR); + zone.spawnEffect(x + sirenOpen.getBlockWidth() / 2.0f - 0.5f, y - sirenOpen.getBlockHeight() / 2.0f + 0.5f, "bomb-electric", 2); } protected int getCurrentWave() { From b5436e44f0a73ecefe7b1f594098fb2506a2883d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 30 Mar 2026 00:18:00 +0200 Subject: [PATCH 7/8] code style --- .../brainwine/gameserver/minigame/GroupDungeonConfig.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java index e482624e..aa567f14 100644 --- a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java +++ b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeonConfig.java @@ -10,7 +10,11 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import java.util.*; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import java.util.stream.Stream; From 6424108e117b43591255cd33867696a9eb90ea63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 3 Apr 2026 02:03:45 +0200 Subject: [PATCH 8/8] implement alternative spawn algorithm in group dungeons --- .../gameserver/minigame/GroupDungeon.java | 85 ++++++++++++++----- .../java/brainwine/gameserver/zone/Zone.java | 3 + 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java index d891a966..b98c9707 100644 --- a/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java +++ b/gameserver/src/main/java/brainwine/gameserver/minigame/GroupDungeon.java @@ -16,6 +16,7 @@ import brainwine.gameserver.player.Player; import brainwine.gameserver.player.TradeSession; import brainwine.gameserver.resource.ResourceFinder; +import brainwine.gameserver.util.MapHelper; import brainwine.gameserver.util.WeightedMap; import brainwine.gameserver.zone.Block; import brainwine.gameserver.zone.MetaBlock; @@ -45,12 +46,17 @@ public class GroupDungeon extends Minigame { Map potencyBumps = new HashMap<>(); List speakers = new ArrayList<>(); List doors = new ArrayList<>(); - int initialNumSpeakers = 0; + int totalWaves; + int currentWave; int enemiesLeftInWave = 0; int enemyInterval; long lastSpawnedAt; private final List spawns = new ArrayList<>(); boolean raidStarted = false; + int prefabLeft; + int prefabRight; + int prefabTop; + int prefabBottom; public static void loadConfig() { logger.info(SERVER_MARKER, "Loading group dungeon configuration ..."); @@ -76,11 +82,11 @@ public GroupDungeon(Zone zone, Player initiator, int x, int y) { public void tick(float deltaTime) { super.tick(deltaTime); long now = System.currentTimeMillis(); - final int currentWave = getCurrentWave(); if(!raidStarted) { if(now >= startedAt + config.getGracePeriod()) { raidStarted = true; + this.currentWave++; enemiesLeftInWave = getTotalEnemiesInWave(currentWave); setDoorsOpen(false); lastSpawnedAt = System.currentTimeMillis(); @@ -96,15 +102,36 @@ public void tick(float deltaTime) { return; } + final int currentWave = this.currentWave; + if(enemiesLeftInWave > 0 && now > enemyInterval + lastSpawnedAt) { // If there are still enemies left to spawn this wave, and it is time to spawn another one lastSpawnedAt = System.currentTimeMillis(); enemyInterval = (int)(500 + Math.random() * 2000); // Pick the enemy table that is only as hard as the current wave or easier WeightedMap currentEnemyTable = config.getEnemies().get(config.getEnemies().keySet().stream().filter(wave -> currentWave >= wave).max(Integer::compareTo).orElse(1)); - MetaBlock speaker = Fake.pickFromList(speakers); + MetaBlock speaker = !speakers.isEmpty() ? Fake.pickFromList(speakers) : null; String entityType = currentEnemyTable.next(); - Npc npc = zone.spawnEntity(entityType, speaker.getX(), speaker.getY()); + + Npc npc = null; + if(speaker != null) { + npc = zone.spawnEntity(entityType, speaker.getX(), speaker.getY()); + } else { + // Make multiple attempts to spawn a raid enemy + for(int attempt = 0; attempt < 20; attempt++) { + int x = prefabLeft + (int)Math.floor(Math.random() * (prefabRight - prefabLeft)); + int y = prefabTop + (int)Math.floor(Math.random() * (prefabBottom - prefabTop)); + + if(!zone.isBlockSolid(x, y)) { + npc = zone.spawnEntity(entityType, x, y); + break; + } + } + + if(npc == null) { + npc = zone.spawnEntity(entityType, this.x, this.y); + } + } if(npc == null) { logger.error("Couldn't spawn entity {}!", entityType); } else { @@ -124,7 +151,7 @@ public void tick(float deltaTime) { } } - if(currentWave >= initialNumSpeakers + 1) { + if(currentWave >= totalWaves) { // If all speakers have been destroyed, complete complete(); return; @@ -132,14 +159,17 @@ public void tick(float deltaTime) { if(enemiesLeftInWave == 0) { // If the wave is over, set up for the next wave - if(currentWave < initialNumSpeakers) { + if(currentWave < totalWaves) { notifyParticipants(String.format("Wave %d is starting!", currentWave + 1)); } - MetaBlock speakerToRemove = Fake.pickFromList(speakers); - Item speakerItem = speakerToRemove.getItem(); - speakers.remove(speakerToRemove); - zone.updateBlock(speakerToRemove.getX(), speakerToRemove.getY(), Layer.FRONT, Item.AIR); - zone.spawnEffect(speakerToRemove.getX() + speakerItem.getBlockWidth() / 2.0f - 0.5f, speakerToRemove.getY() - speakerItem.getBlockHeight() / 2.0f + 0.5f, "bomb-electric", 1); + if(!speakers.isEmpty()) { + MetaBlock speakerToRemove = Fake.pickFromList(speakers); + Item speakerItem = speakerToRemove.getItem(); + speakers.remove(speakerToRemove); + zone.updateBlock(speakerToRemove.getX(), speakerToRemove.getY(), Layer.FRONT, Item.AIR); + zone.spawnEffect(speakerToRemove.getX() + speakerItem.getBlockWidth() / 2.0f - 0.5f, speakerToRemove.getY() - speakerItem.getBlockHeight() / 2.0f + 0.5f, "bomb-electric", 1); + } + this.currentWave++; enemiesLeftInWave = getTotalEnemiesInWave(currentWave + 1); lastSpawnedAt = System.currentTimeMillis(); enemyInterval = (int)(2000 + Math.random() * 8000); @@ -148,7 +178,7 @@ public void tick(float deltaTime) { } private int getTotalEnemiesInWave(int wave) { - return (int)(initialNumSpeakers + (potencyLevel * initialNumSpeakers * wave * wave) / 10.0); + return (int)(totalWaves + (potencyLevel * totalWaves * wave * wave) / 10.0); } @Override @@ -279,6 +309,16 @@ protected void onStart() { return; } + // Read prefab size from the siren's meta-block + Object maybePrefab = siren.getProperty("pre"); + if(maybePrefab instanceof Map) { + Map prefab = (Map)maybePrefab; + prefabTop = MapHelper.getInt(prefab, "t"); + prefabBottom = MapHelper.getInt(prefab, "b"); + prefabLeft = MapHelper.getInt(prefab, "l"); + prefabRight = MapHelper.getInt(prefab, "r"); + } + // Index meta-blocks in this dungeon for(MetaBlock mb : zone.getMetaBlocks()) { if(mb != siren && dungeonId.equals(mb.getStringProperty("@"))) { @@ -290,13 +330,20 @@ protected void onStart() { } } - if(speakers.size() < 3) { - initiator.notify("You can't raid this dungeon anymore because it has been tampered with."); - finish(); - return; + // Compute the total number of waves needed + if(allSpeakerItems.isEmpty()) { + // Assume roughly each 5 by 5 square is a speaker + totalWaves = Math.max(3, Math.abs(prefabRight - prefabLeft) * Math.abs(prefabBottom - prefabTop) / 100); + } else { + if(speakers.size() < 3) { + initiator.notify("You can't raid this dungeon anymore because it has been tampered with."); + finish(); + return; + } + + totalWaves = speakers.size(); } - initialNumSpeakers = speakers.size(); zone.updateBlock(x, y, Layer.FRONT, ItemRegistry.getItem(sirenOpenId)); // Everything went well @@ -355,10 +402,6 @@ protected void complete() { zone.spawnEffect(x + sirenOpen.getBlockWidth() / 2.0f - 0.5f, y - sirenOpen.getBlockHeight() / 2.0f + 0.5f, "bomb-electric", 2); } - protected int getCurrentWave() { - return initialNumSpeakers - speakers.size() + 1; - } - protected void setDoorsOpen(boolean open) { Map toLookFor = new HashMap<>(); for(GroupDungeonConfig.DoorState doorState: config.getDoors()) { diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java index 037a103e..20d8a922 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java @@ -1078,6 +1078,9 @@ public void placePrefab(Prefab prefab, int x, int y, Random random, boolean mirr if(dungeonId != null && (frontItem.getUse(ItemUseType.MINIGAME) instanceof Map && "group-dungeon".equals(((Map)frontItem.getUse(ItemUseType.MINIGAME)).get("type")) || allGroupDungeonItems.contains(frontItem))) { metadata.put("@", dungeonId); + + // Record prefab bounds, used for entity spawning if dungeon speakers aren't configured + metadata.put("pre", MapHelper.map(String.class, Object.class, "l", x, "t", y, "r", Math.min(getWidth(), x + prefab.getWidth()), "b", Math.min(getHeight(), y + prefab.getHeight()), "m", mirrored)); } if(dungeonId != null && frontItem.hasId("mechanical/spawner-brain")) {