Initial commit

This commit is contained in:
2025-09-08 04:39:35 +01:00
commit 73b6e2c156
126 changed files with 6867 additions and 0 deletions

63
server/build.gradle Normal file
View File

@@ -0,0 +1,63 @@
apply plugin: 'application'
java.sourceCompatibility = 21
java.targetCompatibility = 21
if (JavaVersion.current().isJava9Compatible()) {
compileJava.options.release.set(21)
}
mainClassName = 'com.ghost.technic.server.ServerLauncher'
application.setMainClass(mainClassName)
eclipse.project.name = appName + '-server'
dependencies {
implementation 'org.projectlombok:lombok:1.18.38'
annotationProcessor 'org.projectlombok:lombok:1.18.38'
testImplementation 'org.projectlombok:lombok:1.18.38'
testAnnotationProcessor 'org.projectlombok:lombok:1.18.38'
implementation 'ch.qos.logback:logback-classic:1.5.18'
annotationProcessor 'ch.qos.logback:logback-classic:1.5.18'
testImplementation 'ch.qos.logback:logback-classic:1.5.18'
testAnnotationProcessor 'ch.qos.logback:logback-classic:1.5.18'
testImplementation 'org.slf4j:slf4j-api:2.0.17'
// testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.13.1'
// testImplementation 'org.junit.jupiter:junit-jupiter-api:5.13.1'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.13.1'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.13.1'
testImplementation 'org.junit.platform:junit-platform-launcher:1.13.1'
testImplementation 'org.junit.platform:junit-platform-engine:1.13.1'
}
jar {
archiveBaseName.set(appName)
// the duplicatesStrategy matters starting in Gradle 7.0; this setting works.
duplicatesStrategy(DuplicatesStrategy.EXCLUDE)
dependsOn configurations.runtimeClasspath
from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } }
// these "exclude" lines remove some unnecessary duplicate files in the output JAR.
exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA')
dependencies {
exclude('META-INF/INDEX.LIST', 'META-INF/maven/**')
}
// setting the manifest makes the JAR runnable.
manifest {
attributes 'Main-Class': project.mainClassName
}
// this last step may help on some OSes that need extra instruction to make runnable JARs.
doLast {
file(archiveFile).setExecutable(true, false)
}
}
// Equivalent to the jar task; here for compatibility with gdx-setup.
task dist(dependsOn: [jar]) {
}
test {
useJUnitPlatform()
}

View File

@@ -0,0 +1,163 @@
package com.ghost.technic.server;
import com.ghost.technic.server.board.PlayArea;
import com.ghost.technic.server.card.impl.ArchonIdentityCard;
import com.ghost.technic.server.card.keyword.BeforeFightEffect;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.event.EventBus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.util.*;
@Data
@Slf4j
@Builder
@AllArgsConstructor
public class Player {
private String name;
private int amber;
private int keysForged;
@Builder.Default
private PlayArea playArea = new PlayArea();
@Builder.Default
private LinkedList<PlayableCard> deck = new LinkedList<>();
@Builder.Default
private LinkedList<PlayableCard> discardPile = new LinkedList<>();
@Builder.Default
private List<PlayableCard> hand = new ArrayList<>();
@Builder.Default
private List<PlayableCard> archives = new ArrayList<>();
private ArchonIdentityCard archonIdentityCard;
private House activeHouse;
public Player(Player player) {
this.name = player.getName();
this.amber = player.getAmber();
this.keysForged = player.getKeysForged();
this.playArea = player.getPlayArea();
this.deck = player.getDeck();
this.discardPile = player.getDiscardPile();
this.hand = player.getHand();
this.archives = player.getArchives();
this.archonIdentityCard = player.getArchonIdentityCard();
this.activeHouse = player.getActiveHouse();
}
public Set<House> getAvailableHouses() {
var results = new HashSet<House>();
results.addAll(archonIdentityCard.getHouses());
results.addAll(playArea.getAllUsableCardsHouse());
return results;
}
public void drawCards(int count) {
for (int i = 0; i < count && !deck.isEmpty(); i++) {
hand.add(deck.remove(0));
}
}
public void drawCardsToSix() {
// while (hand.size() < 6) {
while (hand.size() < 6 && !deck.isEmpty()) {
drawCards(1);
}
}
public void forgeKey(int keyCost) {
if (amber >= keyCost) {
amber -= keyCost;
keysForged++;
log.info("{} forges a key at cost {}! Keys forged: {}", name, keyCost, keysForged);
} else {
log.info("{} cannot afford a key (needs {}, has {})", name, keyCost, amber);
}
}
public House chooseHouse(Set<House> house) {
// Placeholder for UI, use override for testing
log.info("{} chooses house: {}", name, activeHouse);
return null;
}
public void pickUpArchives() {
if (!archives.isEmpty()) {
hand.addAll(archives);
archives.clear();
log.info("{} picked up archives.", name);
}
}
public void readyCards() {
for (CreatureCard creatureCard : playArea.getBattleline().getCreatures()) {
creatureCard.setReady(true);
}
}
public PlayableCard takeTopCardOfDeck() {
if (!deck.isEmpty()) {
var topCard = deck.getFirst();
deck.remove(topCard);
return topCard;
} else {
return null;
}
}
public void fightWithCreature(CreatureCard creature, CreatureCard target) {
if (!creature.isReady()) return;
creature.beforeFight();
target.beforeFight();
log.info(name + "'s " + creature.getName() + " fights " + target.getName());
// target.takeDamage(creature.);
// creature.takeDamage(target.getPower());
creature.afterFight();
target.afterFight();
creature.setReady(false);
removeDestroyedCreatures();
}
public void reapWithCreature(CreatureCard creature, EventBus eventBus) {
if (!creature.isReady()) {
log.info("Creature is exhausted, cannot reap");
return;
}
creature.beforeReap();
// Perform the actual reap logic (creature gains 1 amber, exhausts, etc.)
creature.reap(this, eventBus);
amber++;
log.info("{} reaps with {} and gains 1 amber.", name, creature.getName());
creature.afterReap();
creature.setReady(false);
}
public List<BeforeFightEffect> chooseBeforeFightOrder(List<BeforeFightEffect> effects) {
// 🔧 TEMP: Default to resolving in order as-is (assault first, then hazardous)
// Later: prompt human or AI to choose order
return effects;
}
public CreatureCard chooseCreature(List<CreatureCard> options, String reason) {
// Placeholder for UI, use override for testing
log.info("[chooseCreature] {} selects creature for: {}", getName(), reason);
return options.stream().findFirst().orElse(null); // pick first for now
}
public void removeDestroyedCreatures() {
playArea.getBattleline().getCreatures().removeIf(card -> {
if (card instanceof CreatureCard creature && creature.isDestroyed()) {
log.info("{}'s {} is removed from battleline.", name, creature.getName());
return true;
}
return false;
});
}
}

View File

@@ -0,0 +1,14 @@
package com.ghost.technic.server;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventBus;
/** Launches the server application. */
public class ServerLauncher {
public static void main(String[] args) {
EventBus eventBus = new EventBus();
GameState gameState = new GameState(eventBus);
gameState.setup();
gameState.run();
}
}

View File

@@ -0,0 +1,70 @@
package com.ghost.technic.server.board;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.*;
@Data
@NoArgsConstructor
public class Battleline {
private LinkedList<CreatureCard> creatures = new LinkedList<>();
/**
* Adds a creature to the specified flank (LEFT or RIGHT).
*/
public void addToFlank(CreatureCard creature, Flank preferredFlank) {
if (creatures.isEmpty()) {
creatures.add(creature);
} else {
switch (preferredFlank) {
case LEFT -> creatures.addFirst(creature);
case RIGHT -> creatures.addLast(creature);
default -> throw new IllegalArgumentException("Unknown flank: " + preferredFlank);
}
}
}
/**
* Returns the immediate neighbors of the given creature in the battleline.
*/
public List<CreatureCard> getNeighbors(CreatureCard creature) {
int index = creatures.indexOf(creature);
List<CreatureCard> neighbors = new ArrayList<>();
if (index > 0) {
neighbors.add(creatures.get(index - 1));
}
if (index >= 0 && index < creatures.size() - 1) {
neighbors.add(creatures.get(index + 1));
}
return neighbors;
}
/**
* Determines whether the creature is at the left or right flank.
* First creature is considered both left and right flank.
*/
public Set<Flank> getEffectiveFlanks(CreatureCard creature) {
Set<Flank> flanks = new HashSet<>();
int index = creatures.indexOf(creature);
if (index == 0) {
flanks.add(Flank.LEFT);
}
if (index == creatures.size() - 1) {
flanks.add(Flank.RIGHT);
}
// If its the only creature, its left, right and center
if (creatures.size() == 1 && index == 0) {
flanks.add(Flank.LEFT);
flanks.add(Flank.CENTER);
flanks.add(Flank.RIGHT);
}
return flanks;
}
}

View File

@@ -0,0 +1,7 @@
package com.ghost.technic.server.board;
public enum Flank {
LEFT,
CENTER,
RIGHT
}

View File

@@ -0,0 +1,29 @@
package com.ghost.technic.server.board;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.ArtifactCard;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Data
@NoArgsConstructor
public class PlayArea {
private Battleline battleline = new Battleline();
private List<ArtifactCard> artifactCards = new ArrayList<>();
public Set<House> getAllUsableCardsHouse() {
var results = new HashSet<House>();
var creatures = getBattleline().getCreatures().stream().map(PlayableCard::getHouse).collect(Collectors.toSet());
var artifact = artifactCards.stream().map(PlayableCard::getHouse).collect(Collectors.toSet());
results.addAll(creatures);
results.addAll(artifact);
return results;
}
}

View File

@@ -0,0 +1,26 @@
package com.ghost.technic.server.card;
import com.ghost.technic.server.card.misc.CardType;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
import java.util.UUID;
@Data
@SuperBuilder
@ToString(onlyExplicitlyIncluded = true)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public abstract class Card {
@ToString.Include
@EqualsAndHashCode.Include
private final UUID id = UUID.randomUUID();
@ToString.Include
@EqualsAndHashCode.Include
private String name;
private CardType cardType;
}

View File

@@ -0,0 +1,9 @@
package com.ghost.technic.server.card.effect;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.game.GameState;
public interface ActivatedAbility {
void activate(GameState gameState, Player controller, PlayableCard source);
}

View File

@@ -0,0 +1,9 @@
package com.ghost.technic.server.card.effect;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.game.GameActionService;
public interface CardAbility {
void register(GameActionService gameActionService, Player controller, PlayableCard source);
}

View File

@@ -0,0 +1,32 @@
package com.ghost.technic.server.card.effect;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameState;
public interface ConstantAbility {
void apply();
void remove();
default boolean canReap(CreatureCard creature, Player actingPlayer, GameState gameState) {
return true; // by default, allow reaping
}
default boolean canFight(CreatureCard creature, Player actingPlayer, GameState gameState) {
return true; // by default, allow reaping
}
default int keyCostModifier(Player forPlayer, GameState gameState) {
return 0; // By default, no change
}
default int armorBonus(CreatureCard target, GameState state) {
return 0; // By default, no change
}
default boolean blanksTextBox(PlayableCard card) {
return false;
}
}

View File

@@ -0,0 +1,11 @@
package com.ghost.technic.server.card.effect;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameState;
public interface ConstraintEffect {
boolean canReap(CreatureCard creature, Player controller, GameState gameState);
boolean canFight(CreatureCard creature, Player controller, GameState gameState);
boolean canUse(CreatureCard creature, Player controller, GameState gameState);
}

View File

@@ -0,0 +1,3 @@
package com.ghost.technic.server.card.effect;
public interface StartOfOpponentTurnAbility extends StartOfTurnAbility {}

View File

@@ -0,0 +1,9 @@
package com.ghost.technic.server.card.effect;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.game.GameActionService;
public interface StartOfTurnAbility extends CardAbility {
void onStartOfTurn(GameActionService gameActionService, Player activePlayer, PlayableCard source);
}

View File

@@ -0,0 +1,11 @@
package com.ghost.technic.server.card.effect;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public abstract class StaticEffectData {
private final CreatureCard joya;
}

View File

@@ -0,0 +1,29 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.event.AmberChangeType;
import com.ghost.technic.server.game.event.EventType;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class AllusionsOfGrandeurAbility implements CardAbility {
@Override
public void register(GameActionService service, Player controller, PlayableCard source) {
Player opponent = service.getGameState().getOpponentOf(controller);
House chosen = controller.chooseHouse(opponent.getArchonIdentityCard().getHouses());
service.getGameState().getEventBus().subscribe(EventType.HOUSE_CHOSEN, event -> {
Player player = event.get("player");
House house = event.get("house");
if (player.equals(opponent) && house != chosen) {
service.adjustAmber(controller, 3, AmberChangeType.GAINED, source);
}
});
}
}

View File

@@ -0,0 +1,28 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.event.EventType;
public class BarristerJoyaAbility implements CardAbility {
@Override
public void register(GameActionService gameActionService, Player controller, PlayableCard source) {
if (!(source instanceof CreatureCard joya)) return;
BarristerJoyaConstantAbility effect = new BarristerJoyaConstantAbility(joya);
effect.apply();
gameActionService.getGameState().registerStaticEffect(joya, effect);
gameActionService.getGameState().getEventBus().subscribe(EventType.CREATURE_DESTROYED, event -> {
CreatureCard destroyed = event.get("creature");
if (destroyed.equals(joya)) {
effect.remove();
gameActionService.getGameState().unregisterStaticEffect(joya);
}
});
}
}

View File

@@ -0,0 +1,46 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.effect.ConstantAbility;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
public class BarristerJoyaConstantAbility implements ConstantAbility {
private final CreatureCard joya;
@Override
public void apply() {
log.info("[apply] Barrister Joyas effect active: enemy creatures cannot reap");
}
@Override
public void remove() {
log.info("[remove] Barrister Joyas effect removed");
}
@Override
public boolean canReap(CreatureCard creature, Player actingPlayer, GameState gameState) {
Player joyaOwner = gameState.getOwnerOf(joya);
Player creatureOwner = gameState.getOwnerOf(creature);
if (creatureOwner == null) {
log.warn("[canReap] No owner found for creature: {}", creature.getName());
}
return creatureOwner == null || creatureOwner.equals(joyaOwner);
}
@Override
public String toString() {
return joya.getName();
}
}

View File

@@ -0,0 +1,30 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
public class BlindingLightAbility implements CardAbility {
@Override
public void register(GameActionService gameActionService, Player controller, PlayableCard source) {
House chosen = controller.chooseHouse(House.getAllHouses());
log.info("{} chooses {} for Blinding Light", controller.getName(), chosen);
List<CreatureCard> targets = gameActionService.getGameState().getAllCreatures().stream()
.filter(c -> c.getHouse() == chosen)
.toList();
for (CreatureCard creature : targets) {
creature.getCounters().addStun(); // assumes a stun counter is supported
log.info("{} is stunned by Blinding Light", creature.getName());
}
}
}

View File

@@ -0,0 +1,27 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class BulwarkAbility implements CardAbility {
@Override
public void register(GameActionService gameActionService, Player controller, PlayableCard source) {
if (!(source instanceof CreatureCard bulwark)) return;
BulwarkConstantAbility effect = new BulwarkConstantAbility(
bulwark,
controller.getPlayArea().getBattleline()
);
gameActionService.getGameState().registerStaticEffect(bulwark, effect);
log.info("Registered Bulwark ability for {}", bulwark.getName());
}
}

View File

@@ -0,0 +1,35 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.board.Battleline;
import com.ghost.technic.server.card.effect.ConstantAbility;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameState;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
@RequiredArgsConstructor
public class BulwarkConstantAbility implements ConstantAbility {
private final CreatureCard bulwark;
private final Battleline battleline;
@Override
public void apply() {
log.info("[apply] Bulwarks effect active: +2 armor to neighbours");
}
@Override
public void remove() {
log.info("[remove] Bulwarks effect active: +2 armor to neighbours");
}
@Override
public int armorBonus(CreatureCard target, GameState state) {
if (!battleline.getCreatures().contains(bulwark)) return 0;
List<CreatureCard> neighbors = battleline.getNeighbors(bulwark);
return neighbors.contains(target) ? 2 : 0;
}
}

View File

@@ -0,0 +1,46 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.AmberChangeType;
import com.ghost.technic.server.game.event.EventType;
import com.ghost.technic.server.game.event.UsageType;
import com.ghost.technic.server.game.event.UsedEventData;
import lombok.extern.slf4j.Slf4j;
import java.util.Set;
@Slf4j
public class DexusTriggeredAbility implements CardAbility {
@Override
public void register(GameActionService gameActionService, Player controller, PlayableCard source) {
if (!(source instanceof CreatureCard dexus)) return;
GameState gameState = gameActionService.getGameState();
// gameState.getEventBus().subscribe(EventType.CREATURE_ENTERED_PLAY, event -> {
gameState.getEventBus().subscribe(EventType.USED, event -> {
UsedEventData usedData = event.get("usedEventData");
PlayableCard playedCard = usedData.getCard();
var usedType = usedData.getUsageType();
if (usedType.equals(UsageType.PLAY)) {
Player playedBy = event.get("player");
Set<Flank> flanks = event.get("flanks");
if (playedBy.equals(gameState.getOwnerOf(dexus))) return;
if (flanks.contains(Flank.RIGHT)) {
gameActionService.adjustAmber(playedBy, -1, AmberChangeType.LOST, dexus);
}
}
});
}
}

View File

@@ -0,0 +1,22 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.StartOfTurnAbility;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.game.GameActionService;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class DirectorZYXAbility implements StartOfTurnAbility {
@Override
public void onStartOfTurn(GameActionService gameActionService, Player activePlayer, PlayableCard source) {
gameActionService.archiveCard(activePlayer.takeTopCardOfDeck(), activePlayer);
log.info("Director of Z.Y.X. archives top card of {}", activePlayer.getName());
}
@Override
public void register(GameActionService gameActionService, Player controller, PlayableCard source) {
// no-op: triggered through GameTurnManager
}
}

View File

@@ -0,0 +1,21 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class MurmookAbility implements CardAbility {
@Override
public void register(GameActionService game, Player controller, PlayableCard source) {
if (!(source instanceof CreatureCard murmook)) return;
MurmookEffect effect = new MurmookEffect(controller);
effect.apply();
game.getGameState().registerStaticEffect(murmook, effect);
}
}

View File

@@ -0,0 +1,30 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.ConstantAbility;
import com.ghost.technic.server.game.GameState;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
public class MurmookEffect implements ConstantAbility {
private final Player controller;
@Override
public void apply() {
log.info("Murmook effect active: opponent's keys cost +1");
}
@Override
public void remove() {
log.info("Murmook effect removed.");
}
@Override
public int keyCostModifier(Player forPlayer, GameState state) {
Player opponent = state.getOpponentOf(controller);
return forPlayer.equals(opponent) ? 1 : 0;
}
}

View File

@@ -0,0 +1,44 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.event.EventType;
import com.ghost.technic.server.game.event.UsageType;
import com.ghost.technic.server.game.event.UsedEventData;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
public class MusthicMurmookAbility implements CardAbility {
@Override
public void register(GameActionService game, Player controller, PlayableCard source) {
if (!(source instanceof CreatureCard musthicMurmook)) return;
// Key cost static effect
MusthicMurmookEffect effect = new MusthicMurmookEffect();
effect.apply();
game.getGameState().registerStaticEffect(musthicMurmook, effect);
game.getGameState().getEventBus().subscribe(EventType.USED, event -> {
UsedEventData usedData = event.get("usedEventData");
PlayableCard playedCard = usedData.getCard();
var usedType = usedData.getUsageType();
if (playedCard.equals(musthicMurmook) && usedType.equals(UsageType.PLAY)) {
List<CreatureCard> choices = game.getGameState().getAllCreaturesInPlay();
CreatureCard target = controller.chooseCreature(choices, "Musthic Murmook: Deal 4 damage");
if (target != null) {
game.dealDamage(target, 4);
log.info("Musthic Murmook deals 4 damage to {}", target.getName());
}
}
});
}
}

View File

@@ -0,0 +1,25 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.ConstantAbility;
import com.ghost.technic.server.game.GameState;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class MusthicMurmookEffect implements ConstantAbility {
@Override
public void apply() {
log.info("Musthic Murmook effect active: everyones keys cost +1");
}
@Override
public void remove() {
log.info("Musthic Murmook effect removed.");
}
@Override
public int keyCostModifier(Player forPlayer, GameState state) {
return 1; // Affects both players
}
}

View File

@@ -0,0 +1,31 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
public class PersistenceHuntingAbility implements CardAbility {
@Override
public void register(GameActionService gameActionService, Player controller, PlayableCard source) {
House chosen = controller.chooseHouse(House.getAllHouses());
log.info("{} chooses {} for Persistence Hunting", controller.getName(), chosen);
Player opponent = gameActionService.getGameState().getOpponentOf(controller);
List<CreatureCard> targets = opponent.getPlayArea().getBattleline().getCreatures().stream()
.filter(creature -> creature.getHouse() == chosen)
.toList();
for (CreatureCard target : targets) {
target.setReady(false); // Exhaust
log.info("{} is exhausted by Persistence Hunting", target.getName());
}
}
}

View File

@@ -0,0 +1,32 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.keyword.BeforeFightAbility;
import com.ghost.technic.server.card.keyword.BeforeFightEffect;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import lombok.extern.slf4j.Slf4j;
import java.util.Optional;
@Slf4j
public class RainceFuryheartAbility implements CardAbility, BeforeFightAbility {
@Override
public void register(GameActionService game, Player controller, PlayableCard source) {
// Maybe static effects or triggers — not needed here
}
@Override
public Optional<BeforeFightEffect> getBeforeFightEffect(CreatureCard self, CreatureCard opponent, GameActionService service) {
return Optional.of(new BeforeFightEffect(
"Exalt the creature " + self.getName() + " fights",
() -> {
log.info("{} Exalting {}",self.getName(), opponent.getName());
opponent.getCounters().addAmber(1); // or however you represent exalt
}
));
}
}

View File

@@ -0,0 +1,30 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.event.EventType;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ScreeyanAbility implements CardAbility {
@Override
public void register(GameActionService service, Player controller, PlayableCard source) {
service.getEventBus().subscribe(EventType.TURN_ENDS, event -> {
Player current = event.get("player");
Player opponent = service.getGameState().getOpponentOf(current);
PlayableCard discardedCard = service.discordTopCardOfDeck(opponent);
if (discardedCard != null) {
House discardedHouse = discardedCard.getHouse();
log.info("[Screeyan] {} discarded. {} may not choose {} next turn.",
discardedCard.getName(), opponent.getName(), discardedHouse);
service.getGameState().restrictHouseNextTurn(opponent, discardedHouse);
}
});
}
}

View File

@@ -0,0 +1,37 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.StartOfOpponentTurnAbility;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
public class SibylWaimareAbility implements StartOfOpponentTurnAbility {
@Override
public void onStartOfTurn(GameActionService gameActionService, Player activePlayer, PlayableCard source) {
Player opponent = gameActionService.getGameState().getOwnerOf(source);
var discardedCard = gameActionService.discordTopCardOfDeck(activePlayer);
if (discardedCard instanceof PlayableCard playCard) {
House house = playCard.getHouse();
List<CreatureCard> creatures = activePlayer.getPlayArea().getBattleline().getCreatures();
for (CreatureCard c : creatures) {
if (c.getHouse() == house) {
c.setReady(false); // exhaust
log.info("Sibyl Waimare exhausts {}", c.getName());
}
}
}
}
@Override
public void register(GameActionService gameActionService, Player controller, PlayableCard source) {
// no-op
}
}

View File

@@ -0,0 +1,38 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.impl.UpgradeCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
public class WayOfTheBearAbility implements CardAbility {
@Override
public void register(GameActionService game, Player controller, PlayableCard source) {
if (!(source instanceof UpgradeCard upgrade)) return;
CreatureCard target = findAttachedCreature(game.getGameState(), upgrade);
if (target == null) return;
var effect = new WayOfTheBearEffect(target);
effect.apply();
game.getGameState().registerStaticEffect(upgrade, effect);
}
private CreatureCard findAttachedCreature(GameState gameState, UpgradeCard upgrade) {
for (Player player : List.of(gameState.getPlayerOne(), gameState.getPlayerTwo())) {
for (CreatureCard creature : player.getPlayArea().getBattleline().getCreatures()) {
if (creature.getUpgrades().contains(upgrade)) return creature;
}
}
return null;
}
}

View File

@@ -0,0 +1,26 @@
package com.ghost.technic.server.card.effect.impl;
import com.ghost.technic.server.card.effect.ConstantAbility;
import com.ghost.technic.server.card.keyword.Keyword;
import com.ghost.technic.server.card.keyword.KeywordAbility;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
public class WayOfTheBearEffect implements ConstantAbility {
private final CreatureCard target;
@Override
public void apply() {
target.getKeywords().add(new Keyword(KeywordAbility.ASSAULT, 2));
log.info("[Way of the Bear] {} gains Assault 2", target.getName());
}
@Override
public void remove() {
target.getKeywords().removeIf(k -> k.getKeywordAbility() == KeywordAbility.ASSAULT && k.getValue() == 2);
}
}

View File

@@ -0,0 +1,18 @@
package com.ghost.technic.server.card.impl;
import com.ghost.technic.server.card.Card;
import com.ghost.technic.server.card.misc.House;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
import java.util.Set;
@Getter
@SuperBuilder
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class ArchonIdentityCard extends Card {
private Set<House> houses;
}

View File

@@ -0,0 +1,10 @@
package com.ghost.technic.server.card.keyword;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import java.util.Optional;
public interface BeforeFightAbility {
Optional<BeforeFightEffect> getBeforeFightEffect(CreatureCard self, CreatureCard opponent, GameActionService service);
}

View File

@@ -0,0 +1,3 @@
package com.ghost.technic.server.card.keyword;
public record BeforeFightEffect(String description, Runnable action) {}

View File

@@ -0,0 +1,26 @@
package com.ghost.technic.server.card.keyword;
import lombok.*;
@Data
@Builder
@AllArgsConstructor
public class Keyword {
private KeywordAbility keywordAbility;
private int value;
public Keyword(KeywordAbility keywordAbility) {
this.keywordAbility = keywordAbility;
this.value = 0;
}
public boolean hasValue() {
return keywordAbility.hasValue();
}
@Override
public String toString() {
return hasValue() ? keywordAbility.name() + " (" + value + ")" : keywordAbility.name();
}
}

View File

@@ -0,0 +1,24 @@
package com.ghost.technic.server.card.keyword;
public enum KeywordAbility {
ALPHA,
ASSAULT, // has value
DEPLOY,
ELUSIVE,
HAZARDOUS, // has value
INVULNERABLE,
OMEGA,
POISON,
SKIRMISH,
SPLASH_ATTACK, // has value
TAUNT,
TREACHERY,
VERSATILE;
public boolean hasValue() {
return switch (this) {
case ASSAULT, HAZARDOUS, SPLASH_ATTACK -> true;
default -> false;
};
}
}

View File

@@ -0,0 +1,13 @@
package com.ghost.technic.server.card.keyword;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
public interface KeywordEffect {
default void onFight(GameActionService service, CreatureCard attacker, CreatureCard defender) {}
default void onReap(GameActionService service, CreatureCard creature) {}
default void onPlay(GameActionService service, PlayableCard card, Player controller) {}
default void onDestroyed(GameActionService service, CreatureCard creature) {}
}

View File

@@ -0,0 +1,26 @@
package com.ghost.technic.server.card.keyword;
import com.ghost.technic.server.card.keyword.impl.*;
import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
public class KeywordEffectRegistry {
private final Map<KeywordAbility, KeywordEffect> registry = new EnumMap<>(KeywordAbility.class);
public KeywordEffectRegistry() {
registry.put(KeywordAbility.SKIRMISH, new SkirmishEffect());
registry.put(KeywordAbility.ELUSIVE, new ElusiveEffect());
registry.put(KeywordAbility.POISON, new PoisonEffect());
registry.put(KeywordAbility.ALPHA, new AlphaEffect());
// registry.put(KeywordAbility.DESTROYED, new DestroyedEffect());
// registry.put(KeywordAbility.SCRAP, new ScrapEffect());
// registry.put(KeywordAbility.OMNI, new OmniEffect());
registry.put(KeywordAbility.DEPLOY, new DeployEffect());
}
public Optional<KeywordEffect> getEffect(KeywordAbility keywordAbility) {
return Optional.ofNullable(registry.get(keywordAbility));
}
}

View File

@@ -0,0 +1,21 @@
package com.ghost.technic.server.card.keyword.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.keyword.KeywordEffect;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.game.GameActionService;
public class AlphaEffect implements KeywordEffect {
// @Override
// public void onPlay(GameActionService service, PlayableCard card, Player controller) {
// if (!service.isFirstCardPlayedThisTurn(card)) {
// throw new IllegalStateException("Alpha cards must be the first card played on your turn.");
// }
// }
@Override
public void onPlay(GameActionService service, PlayableCard card, Player controller) {
if (!service.isFirstCardPlayedThisTurn(card)) {
service.flagRuleViolation(card, "Alpha cards must be the first card played on your turn.");
}
}
}

View File

@@ -0,0 +1,9 @@
package com.ghost.technic.server.card.keyword.impl;
import com.ghost.technic.server.card.keyword.KeywordEffect;
public class DeployEffect implements KeywordEffect {
// Deploy allows placement anywhere on the battleline (not just flanks)
// Usually checked during placement, not as a triggered effect
// GameActionService should consult card.hasKeyword(DEPLOY) when positioning
}

View File

@@ -0,0 +1,13 @@
package com.ghost.technic.server.card.keyword.impl;
import com.ghost.technic.server.card.keyword.KeywordEffect;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
public class DestroyedEffect implements KeywordEffect {
@Override
public void onDestroyed(GameActionService service, CreatureCard creature) {
// Trigger destroyed abilities
service.resolveDestroyedAbilities(creature);
}
}

View File

@@ -0,0 +1,15 @@
package com.ghost.technic.server.card.keyword.impl;
import com.ghost.technic.server.card.keyword.KeywordEffect;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
public class ElusiveEffect implements KeywordEffect {
@Override
public void onFight(GameActionService service, CreatureCard attacker, CreatureCard defender) {
// First time attacked, Elusive prevents all damage to defender
if (service.isFirstAttackAgainst(defender)) {
service.preventDamageTo(defender);
}
}
}

View File

@@ -0,0 +1,8 @@
package com.ghost.technic.server.card.keyword.impl;
import com.ghost.technic.server.card.keyword.KeywordEffect;
public class OmniEffect implements KeywordEffect {
// Omni is more of a tag that allows abilities to be used regardless of house
// Effects using this should check card.hasKeyword(OMNI) before checking house
}

View File

@@ -0,0 +1,13 @@
package com.ghost.technic.server.card.keyword.impl;
import com.ghost.technic.server.card.keyword.KeywordEffect;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
public class PoisonEffect implements KeywordEffect {
@Override
public void onFight(GameActionService service, CreatureCard attacker, CreatureCard defender) {
// Any damage dealt by this creature destroys the other
service.markAsPoisonous(attacker);
}
}

View File

@@ -0,0 +1,16 @@
package com.ghost.technic.server.card.keyword.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.keyword.KeywordEffect;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.game.GameActionService;
public class ScrapEffect implements KeywordEffect {
@Override
public void onPlay(GameActionService service, PlayableCard card, Player controller) {
if (!service.isPlayedFromDiscard(card)) {
throw new IllegalStateException("Scrap effects only trigger when played from discard.");
}
service.resolveScrapEffect(card);
}
}

View File

@@ -0,0 +1,13 @@
package com.ghost.technic.server.card.keyword.impl;
import com.ghost.technic.server.card.keyword.KeywordEffect;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
public class SkirmishEffect implements KeywordEffect {
@Override
public void onFight(GameActionService service, CreatureCard attacker, CreatureCard defender) {
// Skirmish: This creature takes no damage when it is used to fight
service.ignoreRetaliatoryDamage(attacker);
}
}

View File

@@ -0,0 +1,10 @@
package com.ghost.technic.server.card.misc;
import lombok.Data;
import java.util.LinkedList;
@Data
public class BonusIcons {
private LinkedList<IBonusIcon> bonusIcons;
}

View File

@@ -0,0 +1,12 @@
package com.ghost.technic.server.card.misc;
public enum CardType {
ARCHON_IDENTITY,
CREATURE,
ARTIFACT,
ACTION,
UPGRADE,
TIDE,
PROPHECY,
ARCHON_POWER
}

View File

@@ -0,0 +1,24 @@
package com.ghost.technic.server.card.misc;
import java.util.Set;
public enum House {
BROBNAR,
DIS,
EKWIDON,
GEISTOID,
LOGOS,
MARS,
REDEMPTION,
SANCTUM,
SAURIAN,
SHADOWS,
SKYBORN,
STAR_ALLIANCE,
UNFATHOMABLE,
UNTAMED;
public static Set<House> getAllHouses() {
return Set.of(values());
}
}

View File

@@ -0,0 +1,12 @@
package com.ghost.technic.server.card.misc;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
@Getter
@ToString
@RequiredArgsConstructor
public class HouseBonusIcon implements IBonusIcon {
private final House house;
}

View File

@@ -0,0 +1,4 @@
package com.ghost.technic.server.card.misc;
public interface IBonusIcon {
}

View File

@@ -0,0 +1,9 @@
package com.ghost.technic.server.card.misc;
public enum StandardBonusIcon implements IBonusIcon {
ÆMBER,
CAPTURE,
DAMAGE,
DRAW,
DISCARD
}

View File

@@ -0,0 +1,47 @@
package com.ghost.technic.server.card.playable;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.Card;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.misc.BonusIcons;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.game.event.*;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Data
@Slf4j
@SuperBuilder
@ToString(callSuper = true, onlyExplicitlyIncluded = true)
@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true)
public abstract class PlayableCard extends Card {
@ToString.Include
private House house;
private BonusIcons bonusIcons;
@Builder.Default
private List<CardAbility> abilities = new ArrayList<>();
public void play(Player player, EventBus eventBus) {
publishUsedEvent(player, UsageType.PLAY, eventBus);
}
public void publishUsedEvent(Player player, UsageType usageType, EventBus eventBus) {
UsedEventData usedData = new UsedEventData(this, usageType);
Map<String, Object> payload = Map.of("usedEventData", usedData);
GameEvent usedEvent = new GameEvent(EventType.USED, payload);
eventBus.publish(usedEvent);
log.info("{} {} {}", player.getName(), usageType, getName());
}
public void addAbility(CardAbility ability) {
abilities.add(ability);
}
}

View File

@@ -0,0 +1,15 @@
package com.ghost.technic.server.card.playable.impl;
import com.ghost.technic.server.card.playable.PlayableCard;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class ActionCard extends PlayableCard {
// No additional fields for now
}

View File

@@ -0,0 +1,12 @@
package com.ghost.technic.server.card.playable.impl;
import com.ghost.technic.server.card.playable.PlayableCard;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true)
public class UpgradeCard extends PlayableCard {
}

View File

@@ -0,0 +1,19 @@
package com.ghost.technic.server.card.playable.usable;
public interface Usable {
boolean isReady();
void setReady(boolean ready);
default void exhaust() {
setReady(false);
}
default void ready() {
setReady(true);
}
// Cards are played exhausted by default
default void enterPlay() {
exhaust(); // enters play exhausted
}
}

View File

@@ -0,0 +1,23 @@
package com.ghost.technic.server.card.playable.usable.impl;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.Usable;
import com.ghost.technic.server.counter.Counters;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.SuperBuilder;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Data
@Slf4j
@SuperBuilder
@EqualsAndHashCode(callSuper = true)
public class ArtifactCard extends PlayableCard implements Usable {
private boolean ready;
@Builder.Default
private Counters counters = new Counters();
private List<String> traits;
}

View File

@@ -0,0 +1,109 @@
package com.ghost.technic.server.card.playable.usable.impl;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.keyword.Keyword;
import com.ghost.technic.server.card.keyword.KeywordAbility;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.impl.UpgradeCard;
import com.ghost.technic.server.card.playable.usable.Usable;
import com.ghost.technic.server.counter.Counters;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.*;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
import lombok.extern.slf4j.Slf4j;
import java.util.*;
@Data
@Slf4j
@SuperBuilder
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true)
public class CreatureCard extends PlayableCard implements Usable {
private int power;
private int armor;
private boolean ready;
private List<String> traits;
@Builder.Default
private Counters counters = new Counters();
@Builder.Default
private Set<Keyword> keywords = new HashSet<>();
@Builder.Default
private List<UpgradeCard> upgrades = new ArrayList<>();
public void play(Player player, Set<Flank> flanks, EventBus eventBus) {
publishUsedEvent(player, UsageType.PLAY, flanks, eventBus);
}
public void publishUsedEvent(Player player, UsageType usageType, Set<Flank> flanks, EventBus eventBus) {
UsedEventData usedData = new UsedEventData(this, usageType);
Map<String, Object> payload = Map.of(
"usedEventData", usedData,
"flanks", flanks,
"player", player);
GameEvent usedEvent = new GameEvent(EventType.USED, payload);
eventBus.publish(usedEvent);
log.info("{} {} {}", player.getName(), usageType, getName());
}
public int getDamage() {
return counters.getDmg();
}
public void takeDamage(int amount) {
counters.addDmg(amount);
}
public boolean isDestroyed() {
return counters.getDmg() >= power;
}
public void beforeFight() {
log.info("{} triggers before fight effects.", getName());
}
public void afterFight() {
log.info("{} triggers after fight effects.", getName());
}
public void beforeReap() {
log.info("{} triggers before reap effects.", getName());
}
public void fight(Player player, EventBus eventBus) {
publishUsedEvent(player, UsageType.FIGHT, eventBus);
}
public void reap(Player player, EventBus eventBus) {
publishUsedEvent(player, UsageType.REAP, eventBus);
}
public void afterReap() {
log.info("{} triggers after reap effects.", getName());
}
public boolean hasKeyword(KeywordAbility keywordAbility) {
return keywords.stream().anyMatch(k -> k.getKeywordAbility() == keywordAbility);
}
public Optional<Keyword> getKeyword(KeywordAbility keywordAbility) {
return keywords.stream().filter(k -> k.getKeywordAbility() == keywordAbility).findFirst();
}
public int getEffectiveArmor(GameState state) {
return armor + state.getArmorBuffs(this);
}
public int getKeywordValue(KeywordAbility ability) {
return keywords.stream()
.filter(k -> k.getKeywordAbility() == ability)
.map(Keyword::getValue)
.findFirst()
.orElse(0);
}
}

View File

@@ -0,0 +1,13 @@
package com.ghost.technic.server.counter;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
@AllArgsConstructor
public class Counter {
private CounterType counterType;
private int numberOfCounter;
}

View File

@@ -0,0 +1,27 @@
package com.ghost.technic.server.counter;
public enum CounterType {
ÆMBER,
DAMAGE,
WARD,
ENRAGE,
POWER,
STUN,
DOOM,
TIME,
FUSE,
YEA,
NAY,
GLORY,
AWAKENING,
HATCH,
DEPTH,
GROWTH,
SCHEME,
WISDOM,
KNOWLEDGE,
WARRANT,
PAINT,
MINERALIZE,
TRADE
}

View File

@@ -0,0 +1,72 @@
package com.ghost.technic.server.counter;
import lombok.Data;
import java.util.HashSet;
import java.util.Set;
@Data
public class Counters {
private Set<Counter> counters = new HashSet<>();
public int getDmg() {
return counters.stream()
.filter(counter -> counter.getCounterType().equals(CounterType.DAMAGE))
.findFirst()
.map(Counter::getNumberOfCounter)
.orElse(0);
}
public void addDmg(int dmg) {
var dmgToken = getToken(CounterType.DAMAGE);
if (dmgToken != null) {
var newDmg = dmgToken.getNumberOfCounter() + dmg;
dmgToken.setNumberOfCounter(newDmg);
counters.add(dmgToken);
} else {
var dmgTokens = Counter.builder()
.counterType(CounterType.DAMAGE)
.numberOfCounter(dmg)
.build();
counters.add(dmgTokens);
}
}
public int getAmber() {
return counters.stream()
.filter(counter -> counter.getCounterType().equals(CounterType.ÆMBER))
.findFirst()
.map(Counter::getNumberOfCounter)
.orElse(0);
}
public void addAmber(int amber) {
var ambers = getToken(CounterType.ÆMBER);
if (ambers != null) {
var newAmbers = ambers.getNumberOfCounter() + amber;
ambers.setNumberOfCounter(newAmbers);
counters.add(ambers);
} else {
var amberTokens = Counter.builder()
.counterType(CounterType.ÆMBER)
.numberOfCounter(amber)
.build();
counters.add(amberTokens);
}
}
public void addStun() {
var stun = getToken(CounterType.STUN);
if (stun == null) {
counters.add(Counter.builder().counterType(CounterType.STUN).build());
}
}
private Counter getToken(CounterType counterType) {
return counters.stream()
.filter(counter -> counter.getCounterType().equals(counterType))
.findFirst()
.orElse(null);
}
}

View File

@@ -0,0 +1,420 @@
package com.ghost.technic.server.game;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Battleline;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.effect.ConstantAbility;
import com.ghost.technic.server.card.keyword.BeforeFightAbility;
import com.ghost.technic.server.card.keyword.BeforeFightEffect;
import com.ghost.technic.server.card.keyword.KeywordAbility;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.impl.UpgradeCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.event.*;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.*;
@Slf4j
@Getter
@RequiredArgsConstructor
public class GameActionService {
private final GameState gameState;
public void forgeKey(Player player) {
int baseCost = 6;
int modifier = calculateKeyCostModifier(player);
int totalCost = baseCost + modifier;
if (player.getAmber() >= totalCost) {
player.setAmber(player.getAmber() - totalCost);
player.setKeysForged(player.getKeysForged() + 1);
log.info("{} forges a key for {} æmber! Keys forged: {}", player.getName(), totalCost, player.getKeysForged());
getEventBus().publish(new GameEvent(EventType.KEY_FORGED, Map.of(
"player", player,
"cost", totalCost
)));
} else {
log.info("{} could not forge a key (needs {}, has {})", player.getName(), totalCost, player.getAmber());
}
}
public int calculateKeyCostModifier(Player player) {
int modifier = 0;
for (ConstantAbility effect : gameState.getStaticEffects().values()) {
modifier += effect.keyCostModifier(player, gameState);
}
return modifier;
}
public void reap(CreatureCard creature, Player controller) {
// Check if the creature is allowed to be used at all (Rule of Six, exhausted, constant ability etc.)
if (!gameState.canUseCard(creature) || !gameState.canReap(creature, controller)) {
return;
}
// Let the player reflect the reap on their state (gain amber, etc.)
controller.reapWithCreature(creature, getEventBus());
}
public void fight(CreatureCard attacker, CreatureCard defender, Player attackerControllerPlayer) {
if (!gameState.canUseCard(attacker) | !gameState.canFight(attacker, attackerControllerPlayer)) {
log.info("{} cannot be used (exhausted or rule of six)", attacker.getName());
return;
}
attacker.setReady(false);
log.info("{} fights {}", attacker.getName(), defender.getName());
if (beforeFight(attacker, defender)) {
actualFight(attacker, defender);
afterFight(attacker, defender);
}
attacker.fight(attackerControllerPlayer, getEventBus()); // TODO: The timing on this needs to be double checked
}
private boolean beforeFight(CreatureCard attacker, CreatureCard defender) {
List<BeforeFightEffect> effects = new ArrayList<>();
// ASSAULT
int assault = attacker.getKeywordValue(KeywordAbility.ASSAULT);
if (assault > 0) {
effects.add(new BeforeFightEffect("Assault from " + attacker.getName(), () ->
dealDamage(defender, assault)
));
}
// HAZARDOUS
int hazardous = defender.getKeywordValue(KeywordAbility.HAZARDOUS);
if (hazardous > 0) {
effects.add(new BeforeFightEffect("Hazardous from " + defender.getName(), () ->
dealDamage(attacker, hazardous)
));
}
// BEFORE FIGHT ABILITIES — attacker
for (CardAbility ability : attacker.getAbilities()) {
if (ability instanceof BeforeFightAbility bf) {
bf.getBeforeFightEffect(attacker, defender, this).ifPresent(effects::add);
}
}
// BEFORE FIGHT ABILITIES — defender
for (CardAbility ability : defender.getAbilities()) {
if (ability instanceof BeforeFightAbility bf) {
bf.getBeforeFightEffect(defender, attacker, this).ifPresent(effects::add);
}
}
if (effects.isEmpty()) return true;
List<BeforeFightEffect> ordered = gameState.getActivePlayer().chooseBeforeFightOrder(effects);
for (BeforeFightEffect effect : ordered) {
log.info("[Before Fight] Resolving: {}", effect.description());
effect.action().run();
if (attacker.isDestroyed() || defender.isDestroyed()) {
log.info("[Before Fight] One or both creatures destroyed. Fight canceled.");
return false;
}
}
return true;
}
private void actualFight(CreatureCard attacker, CreatureCard defender) {
log.info("[FIGHT] {} ↔ {}", attacker.getName(), defender.getName());
// Attacker damages defender
dealDamage(defender, attacker.getPower());
// Defender damages attacker unless SKIRMISH
if (!attacker.hasKeyword(KeywordAbility.SKIRMISH)) {
dealDamage(attacker, defender.getPower());
} else {
log.info("[SKIRMISH] {} avoids return damage", attacker.getName());
}
}
private void afterFight(CreatureCard attacker, CreatureCard defender) {
// Placeholder for “After Fight” effects (triggers, events, etc.)
// You can add card effect handling or publish AFTER_FIGHT events here
log.info("[AFTER FIGHT] Resolving aftermath of fight.");
}
public void dealDamage(CreatureCard creature, int amount) {
log.info("Dealing {} dmg to {}", amount, creature.getName());
if (creature.getArmor() != 0) {
damagingCreatureWithArmor(creature, amount);
} else {
damageDealt(creature, amount);
}
}
private void damagingCreatureWithArmor(CreatureCard creature, int amount) {
int armor = creature.getArmor();
int difference = armor - amount;
if (difference >= 0) {
// All damage prevented
log.info("{}'s armor prevents all {} dmg ({} Armor remaining)", creature.getName(), amount, difference);
creature.setArmor(difference);
gameState.getEventBus().publish(new GameEvent(EventType.DAMAGE_PREVENTED, Map.of(
"creature", creature,
"amount", amount
)));
} else {
// Some damage goes through
int overflow = amount - armor;
log.info("{}'s armor prevents {} dmg (0 Armor remaining)", creature.getName(), armor);
creature.setArmor(0);
if (armor > 0) {
gameState.getEventBus().publish(new GameEvent(EventType.DAMAGE_PREVENTED, Map.of(
"creature", creature,
"amount", armor
)));
}
damageDealt(creature, overflow);
}
}
private void damageDealt(CreatureCard creature, int amount) {
creature.takeDamage(amount);
log.info("{} takes {} dmg (total: {})", creature.getName(), amount, creature.getDamage());
gameState.getEventBus().publish(new GameEvent(EventType.DAMAGE_TAKEN, Map.of(
"creature", creature,
"amount", amount
)));
}
public void destroyCreature(CreatureCard creature) {
Player owner = gameState.getOwnerOf(creature);
if (owner == null) {
log.warn("Attempted to destroy creature with unknown owner: {}", creature.getName());
return;
}
// Remove from battleline
owner.getPlayArea().getBattleline().getCreatures().remove(creature);
// Move to discard pile
owner.getDiscardPile().add(creature);
log.info("{} is destroyed and moved to {}'s discard pile", creature.getName(), owner.getName());
// Remove any static effects associated with the creature
if (gameState.getStaticEffects().containsKey(creature)) {
log.info("Removing static effect associated with {}", creature.getName());
gameState.getStaticEffects().remove(creature);
}
// Fire destroy event
GameEvent destroyEvent = new GameEvent(EventType.CREATURE_DESTROYED, Map.of(
"creature", creature,
"player", owner
));
getEventBus().publish(destroyEvent);
}
public void adjustAmber(Player player, int delta, AmberChangeType type, PlayableCard source) {
int original = player.getAmber();
int newValue = Math.max(0, original + delta); // Ensure no negative amber
int actualDelta = newValue - original;
if (actualDelta == 0) return;
player.setAmber(newValue);
log.info("{} {} {} amber ({} → {}) from {}",
player.getName(),
(actualDelta > 0 ? "gains" : "loses"),
Math.abs(actualDelta),
original, newValue,
source.getName()
);
AmberChangeEventData eventData = new AmberChangeEventData(player, Math.abs(actualDelta), type, source);
gameState.getEventBus().publish(new GameEvent(EventType.AMBER_CHANGED, Map.of(
"amberEventData", eventData
)));
}
public void playCard(PlayableCard card, Player controller) {
if (card instanceof CreatureCard creature) {
if (creature.hasKeyword(KeywordAbility.ALPHA) && !isFirstCardPlayedThisTurn(card)) {
flagRuleViolation(card, "Alpha cards must be the first card played this turn.");
}
if (creature.hasKeyword(KeywordAbility.OMEGA)) {
markOmegaCardPlayed(card);
}
if (creature.hasKeyword(KeywordAbility.DEPLOY)) {
allowCustomBattlelinePlacement(creature);
}
}
// Register all triggered/static abilities
for (CardAbility ability : card.getAbilities()) {
ability.register(this, controller, card);
}
card.play(controller, getEventBus());
}
public void playCreature(Player player, CreatureCard creature, Flank preferredFlank) {
// Check if the creature is allowed to be used at all (Rule of Six, exhausted, constant ability etc.)
if (!gameState.canUseCard(creature)) {
return;
}
Battleline battleline = player.getPlayArea().getBattleline();
// Add creature to the appropriate side
battleline.addToFlank(creature, preferredFlank);
// Compute effective flank(s)
Set<Flank> effectiveFlanks = battleline.getEffectiveFlanks(creature);
// Publish CREATURE_ENTERED_PLAY event
gameState.getEventBus().publish(new GameEvent(EventType.CREATURE_ENTERED_PLAY, Map.of(
"creature", creature,
"player", player,
"flanks", effectiveFlanks,
"gameState", gameState
)));
// Register all triggered/static abilities
for (CardAbility ability : creature.getAbilities()) {
ability.register(this, player, creature);
}
creature.play(player, effectiveFlanks, getEventBus());
creature.setReady(false);
}
public void playUpgrade(Player player, UpgradeCard upgrade, CreatureCard target) {
// Check rule-of-six or usage limits if applicable
if (!gameState.canUseCard(upgrade)) {
log.info("Upgrade {} cannot be used due to Rule of Six.", upgrade.getName());
return;
}
// Attach the upgrade to the target creature
target.getUpgrades().add(upgrade);
upgrade.play(player, getEventBus());
gameState.setOwner(upgrade, player);
// Register upgrade abilities
for (CardAbility ability : upgrade.getAbilities()) {
ability.register(this, player, upgrade);
}
log.info("{} attaches upgrade {} to {}", player.getName(), upgrade.getName(), target.getName());
}
public void archiveCard(PlayableCard card, Player player) {
if (card != null) {
player.getArchives().add(card);
}
}
public PlayableCard discordTopCardOfDeck(Player player) {
return discardCard(player.takeTopCardOfDeck(), player);
}
public PlayableCard discardCard(PlayableCard card, Player player) {
if (card != null) {
player.getDiscardPile().add(card);
return card;
} else {
return null;
}
}
public void chooseActiveHouse(Player player) {
Set<House> identityHouses = player.getAvailableHouses();
Set<House> restricted = gameState.getRestrictedHouses(player);
Set<House> valid = new HashSet<>(identityHouses);
valid.removeAll(restricted);
House chosen = player.chooseHouse(valid); // Delegate UI/input to player
log.info("{} chooses house: {}", player.getName(), chosen);
gameState.getEventBus().publish(new GameEvent(EventType.HOUSE_CHOSEN, Map.of(
"player", player,
"house", chosen
)));
gameState.clearHouseRestrictions(player);
}
public House chhoseAHouse() {
return null;
}
public EventBus getEventBus() {
return gameState.getEventBus();
}
// Placeholder method signatures
public void ignoreRetaliatoryDamage(CreatureCard attacker) {
}
public boolean isFirstAttackAgainst(CreatureCard defender) {
return true;
}
public void preventDamageTo(CreatureCard creature) {
}
public void markAsPoisonous(CreatureCard creature) {
}
public void dealHazardousDamage(CreatureCard from, CreatureCard to, int amount) {
}
public void dealAssaultDamage(CreatureCard from, CreatureCard to, int amount) {
}
public void dealSplashDamage(CreatureCard from, CreatureCard primaryTarget, int splashAmount) {
}
public boolean isFirstCardPlayedThisTurn(PlayableCard card) {
return true;
}
public void flagRuleViolation(PlayableCard card, String reason) {
System.out.println("[RULE] " + card.getName() + ": " + reason);
}
public void markOmegaCardPlayed(PlayableCard card) {
}
public void allowCustomBattlelinePlacement(CreatureCard creature) {
}
public boolean isPlayedFromDiscard(PlayableCard card) {
// Check discard context or play source
return false;
}
public void resolveDestroyedAbilities(CreatureCard creature) {
// Trigger destroyed abilities from effects or cards
}
public void resolveScrapEffect(PlayableCard card) {
// Trigger scrap ability from card text or linked ability
}
}

View File

@@ -0,0 +1,194 @@
package com.ghost.technic.server.game;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.ConstantAbility;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.event.EventBus;
import com.ghost.technic.server.game.event.EventType;
import com.ghost.technic.server.game.event.UsedEventData;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.*;
@Data
@Slf4j
@RequiredArgsConstructor
public class GameState {
private Player playerOne;
private Player playerTwo;
private Player activePlayer;
private int turnCount = 0;
private Map<PlayableCard, ConstantAbility> staticEffects = new HashMap<>();
private Map<PlayableCard, Player> cardOwnership = new HashMap<>();
private Map<String, Integer> ruleOfSixCounter = new HashMap<>();
private final EventBus eventBus;
private final Map<Player, Set<House>> nextTurnHouseRestrictions = new HashMap<>();
private final Map<Player, House> forcedHouseChoiceNextTurn = new HashMap<>();
private GameTurnManager turnManager;
public void setup() {
playerOne = Player.builder().name("Archon A").build();
playerTwo = Player.builder().name("Archon B").build();
playerOne.drawCards(7);
playerTwo.drawCards(6);
activePlayer = playerOne; // Could be randomized
}
// Assign players (optional setter if needed)
public void setPlayers(Player playerOne, Player playerTwo) {
this.playerOne = playerOne;
this.playerTwo = playerTwo;
}
public void run() {
while (!isGameOver()) {
turnManager.takeTurn(activePlayer);
}
log.info("Game over! Winner: {}", getWinner().getName());
}
// public void takeTurn(Player player) {
// log.info("\nTurn {}: {}", turnCount + 1, player.getName());
// player.forgeKey();
// player.chooseHouse();
// player.pickUpArchives();
// player.playCards();
// player.removeDestroyedCreatures();
// player.readyCards();
// player.drawCardsToSix();
// }
public void switchActivePlayer() {
activePlayer = (activePlayer == playerOne) ? playerTwo : playerOne;
}
public boolean isGameOver() {
return playerOne.getKeysForged() >= 3 || playerTwo.getKeysForged() >= 3;
}
public Player getWinner() {
return playerOne.getKeysForged() >= 3 ? playerOne : playerTwo;
}
public void registerStaticEffect(PlayableCard card, ConstantAbility effect) {
staticEffects.put(card, effect);
}
public void unregisterStaticEffect(PlayableCard card) {
staticEffects.remove(card);
}
public void cleanup() {
staticEffects.values().forEach(ConstantAbility::remove);
staticEffects.clear();
}
public void trackRuleOfSix() {
eventBus.subscribe(EventType.USED, event -> {
UsedEventData usedData = event.get("usedEventData");
PlayableCard card = usedData.getCard();
String cardName = card.getName();
int count = ruleOfSixCounter.getOrDefault(cardName, 0);
ruleOfSixCounter.put(cardName, count + 1);
});
}
public boolean canReap(CreatureCard creature, Player actingPlayer) {
log.info("{} use {} to reap", actingPlayer.getName(), creature.getName());
for (ConstantAbility effect : staticEffects.values()) {
if (!effect.canReap(creature, actingPlayer, this)) {
log.info("{} cannot reap due to {}'s constant ability.", creature.getName(), effect);
return false;
}
}
return true;
}
public boolean canFight(CreatureCard creature, Player actingPlayer) {
log.info("{} use {} to fight", actingPlayer.getName(), creature.getName());
for (ConstantAbility effect : staticEffects.values()) {
if (!effect.canFight(creature, actingPlayer, this)) {
log.info("{} cannot fight due to {}'s constant ability.", creature.getName(), effect);
return false;
}
}
return true;
}
public void setOwner(PlayableCard card, Player player) {
cardOwnership.put(card, player);
}
public Player getOwnerOf(PlayableCard card) {
return cardOwnership.get(card);
}
boolean canUseCard(PlayableCard card) {
if (!(ruleOfSixCounter.getOrDefault(card.getName(), 0) < 6)) {
log.info("{} cannot be used due to Rule of Six.", card.getName());
return false;
}
;
return true;
}
public List<CreatureCard> getAllCreaturesInPlay() {
List<CreatureCard> all = new ArrayList<>();
if (playerOne != null) {
all.addAll(playerOne.getPlayArea().getBattleline().getCreatures());
}
if (playerTwo != null) {
all.addAll(playerTwo.getPlayArea().getBattleline().getCreatures());
}
return all;
}
public Player getOpponentOf(Player controller) {
if (controller.equals(playerOne)) {
return playerTwo;
} else {
return playerOne;
}
}
public int getArmorBuffs(CreatureCard creature) {
return staticEffects.entrySet().stream()
.filter(entry -> !isTextBoxBlanked(entry.getKey()))
.map(Map.Entry::getValue)
.mapToInt(effect -> effect.armorBonus(creature, this))
.sum();
}
public boolean isTextBoxBlanked(PlayableCard card) {
return staticEffects.values().stream()
.anyMatch(effect -> effect.blanksTextBox(card));
}
public List<CreatureCard> getAllCreatures() {
var results = new ArrayList<CreatureCard>();
var playerOneCreatures = playerOne.getPlayArea().getBattleline().getCreatures();
var playerTwoCreatures = playerTwo.getPlayArea().getBattleline().getCreatures();
results.addAll(playerOneCreatures);
results.addAll(playerTwoCreatures);
return results;
}
public void restrictHouseNextTurn(Player player, House house) {
nextTurnHouseRestrictions.computeIfAbsent(player, k -> new HashSet<>()).add(house);
}
public Set<House> getRestrictedHouses(Player player) {
return nextTurnHouseRestrictions.getOrDefault(player, Set.of());
}
public void clearHouseRestrictions(Player player) {
nextTurnHouseRestrictions.remove(player);
}
}

View File

@@ -0,0 +1,71 @@
package com.ghost.technic.server.game;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.CardAbility;
import com.ghost.technic.server.card.effect.StartOfOpponentTurnAbility;
import com.ghost.technic.server.card.effect.StartOfTurnAbility;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.event.EventType;
import com.ghost.technic.server.game.event.GameEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.Map;
@Slf4j
@RequiredArgsConstructor
public class GameTurnManager {
private final GameState gameState;
private final GameActionService gameActionService;
public void takeTurn(Player player) {
log.info("== Turn {}: {} ==", gameState.getTurnCount() + 1, player.getName());
// “Start of turn” effect(s) resolve.
handleStartOfTurnAbilities(player);
// Forge a key step
gameActionService.forgeKey(player);
// Choose which house will be the active house for this turn.
gameActionService.chooseActiveHouse(player);
// player.chooseHouse();
// “After you choose a house” effects resolve.
// STEP 3: PLAY, DISCARD, OR USE CARDS
player.readyCards();
player.drawCardsToSix();
// “End of turn” effects resolve.
}
public void endOfTurn(Player player) {
var endOfTurnGameEvent = new GameEvent(EventType.TURN_ENDS , Map.of("player", player));
gameState.getEventBus().publish(endOfTurnGameEvent);
}
public void handleStartOfTurnAbilities(Player activePlayer) {
List<CreatureCard> allCreatures = gameState.getAllCreaturesInPlay();
for (CreatureCard creature : allCreatures) {
for (CardAbility ability : creature.getAbilities()) {
if (ability instanceof StartOfTurnAbility startOfTurnAbility) {
boolean isOwnedByActivePlayer = gameState.getOwnerOf(creature).equals(activePlayer);
boolean isOpponentAbility = !isOwnedByActivePlayer;
// Only trigger relevant abilities
if ((startOfTurnAbility instanceof StartOfOpponentTurnAbility && isOpponentAbility) ||
(!(startOfTurnAbility instanceof StartOfOpponentTurnAbility) && isOwnedByActivePlayer)) {
startOfTurnAbility.onStartOfTurn(gameActionService, activePlayer, creature);
}
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
package com.ghost.technic.server.game.event;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.playable.PlayableCard;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class AmberChangeEventData {
private final Player player;
private final int amount;
private final AmberChangeType changeType;
private final PlayableCard source;
}

View File

@@ -0,0 +1,8 @@
package com.ghost.technic.server.game.event;
public enum AmberChangeType {
GAINED,
LOST,
CAPTURED,
STOLEN
}

View File

@@ -0,0 +1,10 @@
package com.ghost.technic.server.game.event;
import com.ghost.technic.server.Player;
import lombok.Value;
@Value
public class AmberEventData {
Player player;
int amountChanged; // use negative for loss, positive for gain
}

View File

@@ -0,0 +1,30 @@
package com.ghost.technic.server.game.event;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
public class EventBus {
private final Map<EventType, List<EventListener>> listeners = new EnumMap<>(EventType.class);
public void subscribe(EventType type, EventListener listener) {
listeners.computeIfAbsent(type, k -> new ArrayList<>()).add(listener);
}
public void unsubscribe(EventType type, EventListener listener) {
if (listeners.containsKey(type)) {
listeners.get(type).remove(listener);
}
}
public void publish(GameEvent event) {
List<EventListener> eventListeners = listeners.get(event.getType());
if (eventListeners != null) {
for (EventListener listener : new ArrayList<>(eventListeners)) {
listener.handle(event);
}
}
}
}

View File

@@ -0,0 +1,6 @@
package com.ghost.technic.server.game.event;
@FunctionalInterface
public interface EventListener {
void handle(GameEvent event);
}

View File

@@ -0,0 +1,17 @@
package com.ghost.technic.server.game.event;
public enum EventType {
TURN_STARTED,
TURN_ENDS,
CREATURE_ENTERED_PLAY,
CREATURE_DESTROYED,
HOUSE_CHOSEN,
USED, // unified event for PLAY, REAP, FIGHT, ACTION, etc.
CARD_DISCARDED,
CARD_DRAWN,
CARD_ARCHIVED,
DAMAGE_TAKEN,
DAMAGE_PREVENTED,
KEY_FORGED,
AMBER_CHANGED
}

View File

@@ -0,0 +1,19 @@
package com.ghost.technic.server.game.event;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Map;
@Getter
@RequiredArgsConstructor
public class GameEvent {
private final EventType type;
private final Map<String, Object> payload;
@SuppressWarnings("unchecked")
public <T> T get(String key) {
return (T) payload.get(key);
}
}

View File

@@ -0,0 +1,8 @@
package com.ghost.technic.server.game.event;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameState;
public interface TriggeredAbility {
void register(EventBus eventBus, CreatureCard source, GameState gameState);
}

View File

@@ -0,0 +1,10 @@
package com.ghost.technic.server.game.event;
public enum UsageType {
REAP,
FIGHT,
ACTION,
OMNI,
PLAY,
UNSTUN
}

View File

@@ -0,0 +1,12 @@
package com.ghost.technic.server.game.event;
import com.ghost.technic.server.card.playable.PlayableCard;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class UsedEventData {
private final PlayableCard card;
private final UsageType usageType;
}

View File

@@ -0,0 +1,72 @@
package com.ghost.technic.server.card;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.card.effect.impl.AllusionsOfGrandeurAbility;
import com.ghost.technic.server.card.impl.ArchonIdentityCard;
import com.ghost.technic.server.card.playable.impl.ActionCard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventBus;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AllusionsOfGrandeurTest {
GameActionService actionService;
GameState gameState;
Player alice;
Player bob;
@BeforeEach
void setUp() {
gameState = new GameState(new EventBus());
actionService = new GameActionService(gameState);
alice = Player.builder().name("Alice").archonIdentityCard(ArchonIdentityCard.builder().houses(Set.of(House.BROBNAR, House.SHADOWS, House.UNTAMED)).build()).build();
// Alice picks House Sanctum, if opponent does not pick Sanctum, Alice gains 3 amber
alice = new Player(alice) {
@Override
public House chooseHouse(Set<House> house) {
return House.SANCTUM;
}
};
bob = Player.builder().name("Bob").archonIdentityCard(ArchonIdentityCard.builder().houses(Set.of(House.SANCTUM, House.LOGOS, House.MARS)).build()).deck(new LinkedList<>(List.of(CreatureCard.builder().name("Some Mars Creature").house(House.MARS).build()))).build();
bob = new Player(bob) {
@Override
public House chooseHouse(Set<House> house) {
return House.MARS;
}
};
gameState.setPlayers(alice, bob);
gameState.setActivePlayer(alice);
}
@Test
void testGainAmberIfOpponentDoesNotChooseDeclaredHouse() {
// Alice plays Allusions of Grandeur and picks BROBNAR
ActionCard allusions = ActionCard.builder().name("Allusions of Grandeur").house(House.UNFATHOMABLE).abilities(List.of(new AllusionsOfGrandeurAbility())).build();
actionService.playCard(allusions, alice);
assertEquals(0, alice.getAmber());
// Bob chooses a different house (LOGOS)
gameState.setActivePlayer(bob);
bob.setAmber(0);
actionService.chooseActiveHouse(bob);
assertEquals(3, alice.getAmber(), "Alice should gain 3 amber because Bob did not choose BROBNAR");
}
}

View File

@@ -0,0 +1,125 @@
package com.ghost.technic.server.card;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.effect.impl.BarristerJoyaAbility;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventBus;
import com.ghost.technic.server.game.event.EventType;
import com.ghost.technic.server.game.event.GameEvent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class BarristerJoyaTest {
GameActionService gameActionService;
Player you;
Player opponent;
CreatureCard joya;
CreatureCard enemyBadPenny;
CreatureCard enemyRadPenny;
@BeforeEach
void setUp() {
gameActionService = new GameActionService(new GameState(new EventBus()));
you = Player.builder().name("You").build();
opponent = Player.builder().name("Opponent").build();
joya = CreatureCard.builder()
.name("Barrister Joya")
.house(House.SANCTUM)
.power(4)
.armor(2)
.abilities(List.of(new BarristerJoyaAbility()))
.build();
enemyBadPenny = CreatureCard.builder()
.name("Bad Penny")
.house(House.SHADOWS)
.power(1)
.armor(0)
.build();
enemyRadPenny = CreatureCard.builder()
.name("Rad Penny")
.house(House.SHADOWS)
.power(1)
.armor(0)
.build();
GameState state = gameActionService.getGameState();
state.setOwner(joya, you);
state.setOwner(enemyBadPenny, opponent);
state.setOwner(enemyRadPenny, opponent);
}
@Test
void testJoyaBlocksEnemyReapButNotFriendly() {
var friendlyYurk = CreatureCard.builder()
.name("Yurk")
.house(House.DIS)
.power(4)
.armor(0)
.build();
gameActionService.getGameState().setOwner(friendlyYurk, you);
gameActionService.playCreature(you, joya, Flank.LEFT);
gameActionService.playCreature(you, friendlyYurk, Flank.RIGHT);
gameActionService.playCreature(opponent, enemyBadPenny, Flank.RIGHT);
enemyBadPenny.setReady(true);
gameActionService.reap(enemyBadPenny, opponent);
assertEquals(0, opponent.getAmber(), "Enemy should NOT reap while Joya is in play");
friendlyYurk.setReady(true);
gameActionService.reap(friendlyYurk, you);
assertEquals(1, you.getAmber(), "Friendly should reap normally");
}
@Test
void testEnemyPlayedAfterJoyaCannotReap() {
gameActionService.playCreature(you, joya, Flank.LEFT);
gameActionService.playCreature(opponent, enemyBadPenny, Flank.RIGHT);
enemyBadPenny.setReady(true);
gameActionService.reap(enemyBadPenny, opponent);
assertEquals(0, opponent.getAmber(), "Enemy creature played after Joya should not reap");
}
@Test
void testMultipleEnemyCreaturesCannotReap() {
gameActionService.playCreature(you, joya, Flank.LEFT);
gameActionService.playCreature(opponent, enemyBadPenny, Flank.LEFT);
gameActionService.playCreature(opponent, enemyRadPenny, Flank.RIGHT);
enemyBadPenny.setReady(true);
enemyRadPenny.setReady(true);
gameActionService.reap(enemyBadPenny, opponent);
gameActionService.reap(enemyRadPenny, opponent);
assertEquals(0, opponent.getAmber(), "No enemy should reap while Joya is active");
}
@Test
void testEnemyCanReapAfterJoyaDestroyed() {
gameActionService.playCreature(you, joya, Flank.LEFT);
gameActionService.playCreature(opponent, enemyBadPenny, Flank.RIGHT);
// Destroy Joya
GameEvent destroyEvent = new GameEvent(EventType.CREATURE_DESTROYED, Map.of("creature", joya));
gameActionService.getEventBus().publish(destroyEvent);
enemyBadPenny.setReady(true);
gameActionService.reap(enemyBadPenny, opponent);
assertEquals(1, opponent.getAmber(), "Enemy should reap after Joya is destroyed");
}
}

View File

@@ -0,0 +1,87 @@
package com.ghost.technic.server.card;
import com.ghost.technic.server.card.effect.impl.BulwarkAbility;
import com.ghost.technic.server.counter.Counters;
import lombok.Data;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventBus;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@Data
class BulwarkTest {
private Player player;
private CreatureCard bulwark;
private CreatureCard leftNeighbor;
private CreatureCard rightNeighbor;
private GameActionService gameActionService;
@BeforeEach
void setUp() {
gameActionService = new GameActionService(new GameState(new EventBus()));
player = Player.builder().name("TestPlayer").build();
gameActionService.getGameState().setPlayers(player, Player.builder().name("Opponent").build());
bulwark = CreatureCard.builder()
.name("Bulwark")
.house(House.SANCTUM)
.power(4)
.armor(2)
.traits(List.of("Human", "Knight"))
.abilities(List.of(new BulwarkAbility()))
.counters(new Counters())
.build();
leftNeighbor = CreatureCard.builder()
.name("Lefty")
.house(House.BROBNAR)
.power(3)
.armor(1)
.traits(List.of("Beast"))
.counters(new Counters())
.build();
rightNeighbor = CreatureCard.builder()
.name("Righty")
.house(House.BROBNAR)
.power(3)
.armor(1)
.traits(List.of("Beast"))
.counters(new Counters())
.build();
// Add cards to battleline: Lefty - Bulwark - Righty
gameActionService.playCreature(player, leftNeighbor, Flank.LEFT);
gameActionService.playCreature(player, bulwark, Flank.RIGHT);
gameActionService.playCreature(player, rightNeighbor, Flank.RIGHT);
}
@Test
void testBulwarkGrantsArmorToNeighbors() {
assertEquals(3, leftNeighbor.getEffectiveArmor(gameActionService.getGameState()), "Left neighbor should have +2 armor");
assertEquals(3, rightNeighbor.getEffectiveArmor(gameActionService.getGameState()), "Right neighbor should have +2 armor");
}
@Test
void testBulwarkStaticEffectRemovedOnDestroy() {
// BulwarkAbility should have already subscribed during setup
gameActionService.getGameState().setOwner(bulwark, player);
gameActionService.destroyCreature(bulwark);
// Ensure Bulwark's effect has been unregistered
assertFalse(gameActionService.getGameState().getStaticEffects().containsKey(bulwark),
"Bulwark's static effect should be removed from game state");
}
}

View File

@@ -0,0 +1,126 @@
package com.ghost.technic.server.card;
import com.ghost.technic.server.card.effect.impl.DexusTriggeredAbility;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventBus;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DexusTest {
GameActionService gameActionService;
Player alice; // controller of Dexus
Player bob; // opponent
CreatureCard dexus;
@BeforeEach
void setUp() {
gameActionService = new GameActionService(new GameState(new EventBus()));
alice = Player.builder().name("Alice").build();
bob = Player.builder().name("Bob").build();
dexus = CreatureCard.builder()
.name("Dexus")
.house(House.DIS)
.power(3)
.armor(1)
.abilities(List.of(new DexusTriggeredAbility()))
.build();
gameActionService.getGameState().setPlayers(alice, bob);
gameActionService.getGameState().setOwner(dexus, alice);
gameActionService.playCreature(alice, dexus, Flank.LEFT);
}
private CreatureCard makeCreature(String name, House house) {
return CreatureCard.builder()
.name(name)
.house(house)
.power(2)
.armor(0)
.build();
}
@Test
void friendlyCreaturePlayedOnRightFlank_shouldNotLoseAmber() {
CreatureCard friendly = makeCreature("Friendly Minion", House.DIS);
gameActionService.getGameState().setOwner(friendly, alice);
alice.setAmber(3);
gameActionService.playCreature(alice, friendly, Flank.RIGHT);
assertEquals(3, alice.getAmber(), "Alice should not lose amber when playing to right flank");
}
@Test
void friendlyCreaturePlayedOnLeftFlank_shouldNotLoseAmber() {
CreatureCard friendly = makeCreature("Friendly Minion", House.DIS);
gameActionService.getGameState().setOwner(friendly, alice);
alice.setAmber(3);
gameActionService.playCreature(alice, friendly, Flank.LEFT);
assertEquals(3, alice.getAmber(), "Alice should not lose amber when playing to left flank");
}
@Test
void enemyCreaturePlayedOnRightFlank_shouldLoseAmber() {
CreatureCard enemy = makeCreature("Enemy Minion", House.SANCTUM);
gameActionService.getGameState().setOwner(enemy, bob);
bob.setAmber(2);
gameActionService.playCreature(bob, enemy, Flank.RIGHT);
assertEquals(1, bob.getAmber(), "Bob should lose 1 amber for playing to right flank");
}
@Test
void enemyCreaturePlayedOnLeftFlank_shouldLoseAmberDueToBeingTheOnlyOne() {
CreatureCard enemy = makeCreature("Enemy Minion", House.SANCTUM);
gameActionService.getGameState().setOwner(enemy, bob);
bob.setAmber(2);
gameActionService.playCreature(bob, enemy, Flank.LEFT);
assertEquals(1, bob.getAmber(), "Bob should lose amber even for playing to left flank because there's no other creature");
}
@Test
void enemyPlaysTwoCreaturesToRightFlank_shouldLoseAmberTwice() {
CreatureCard e1 = makeCreature("Righty1", House.SANCTUM);
CreatureCard e2 = makeCreature("Righty2", House.SANCTUM);
gameActionService.getGameState().setOwner(e1, bob);
gameActionService.getGameState().setOwner(e2, bob);
bob.setAmber(3);
gameActionService.playCreature(bob, e1, Flank.RIGHT);
assertEquals(2, bob.getAmber(), "Bob should lose 1 amber for first creature being played");
gameActionService.playCreature(bob, e2, Flank.RIGHT);
assertEquals(1, bob.getAmber(), "Bob should lose second amber for a creature being played on the right flank");
}
@Test
void enemyPlaysTwoCreaturesToLeftFlank_shouldLoseAmberForTheFirstCreature() {
CreatureCard e1 = makeCreature("Lefty1", House.SANCTUM);
CreatureCard e2 = makeCreature("Lefty2", House.SANCTUM);
gameActionService.getGameState().setOwner(e1, bob);
gameActionService.getGameState().setOwner(e2, bob);
bob.setAmber(3);
gameActionService.playCreature(bob, e1, Flank.LEFT);
assertEquals(2, bob.getAmber(), "Bob should lose 1 amber due to it being the only creature");
gameActionService.playCreature(bob, e2, Flank.LEFT);
assertEquals(2, bob.getAmber(), "Bob should not lose amber for left-flank plays");
}
}

View File

@@ -0,0 +1,65 @@
package com.ghost.technic.server.card;
import com.ghost.technic.server.card.effect.impl.DirectorZYXAbility;
import com.ghost.technic.server.card.impl.ArchonIdentityCard;
import com.ghost.technic.server.game.GameTurnManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventBus;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DirectorZYXTest {
GameActionService gameActionService;
GameState gameState;
Player player;
CreatureCard topCreatureCard;
CreatureCard secondCreatureCard;
@BeforeEach
void setUp() {
gameState = new GameState(new EventBus());
gameActionService = new GameActionService(gameState);
ArchonIdentityCard archonIdentityCard = ArchonIdentityCard.builder().houses(Set.of(House.SANCTUM, House.LOGOS, House.UNFATHOMABLE)).build();
player = Player.builder().name("Z.Y.X. Player").archonIdentityCard(archonIdentityCard).build();
topCreatureCard = CreatureCard.builder().name("Top card").build();
secondCreatureCard = CreatureCard.builder().name("Second card").build();
player.getDeck().addAll(List.of(topCreatureCard, secondCreatureCard));
gameState.setPlayers(player, Player.builder().name("Opponent").build());
gameState.setOwner(player.getDeck().getFirst(), player);
CreatureCard director = CreatureCard.builder()
.name("Director of Z.Y.X.")
.house(House.LOGOS)
.power(3)
.abilities(List.of(new DirectorZYXAbility()))
.build();
gameState.setOwner(director, player);
gameActionService.playCreature(player, director, Flank.LEFT);
}
@Test
void testDirectorZYXArchivesTopCard() {
int before = player.getArchives().size();
GameTurnManager turnManager = new GameTurnManager(gameState, gameActionService);
turnManager.handleStartOfTurnAbilities(player);
assertEquals(before + 1, player.getArchives().size(), "Should archive one card at start of turn");
assertEquals(topCreatureCard, player.getArchives().getFirst());
assertEquals(secondCreatureCard, player.getDeck().getFirst());
}
}

View File

@@ -0,0 +1,48 @@
package com.ghost.technic.server.card;
import com.ghost.technic.server.card.effect.impl.MurmookAbility;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventBus;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class MurmookTest {
GameActionService actionService;
GameState gameState;
Player alice;
Player bob;
@BeforeEach
void setup() {
gameState = new GameState(new EventBus());
actionService = new GameActionService(gameState);
alice = Player.builder().name("Alice").build();
bob = Player.builder().name("Bob").build();
gameState.setPlayers(alice, bob);
}
@Test
void testMurmookIncreasesOpponentKeyCost() {
CreatureCard murmook = CreatureCard.builder()
.name("Murmook")
.power(3)
.armor(0)
.abilities(List.of(new MurmookAbility()))
.build();
gameState.setOwner(murmook, alice);
actionService.playCreature(alice, murmook, Flank.LEFT);
assertEquals(1, actionService.calculateKeyCostModifier(bob), "Bob's key cost should be +1");
assertEquals(0, actionService.calculateKeyCostModifier(alice), "Alice's key cost should be unaffected");
}
}

View File

@@ -0,0 +1,74 @@
package com.ghost.technic.server.card;
import com.ghost.technic.server.card.effect.impl.MusthicMurmookAbility;
import com.ghost.technic.server.card.keyword.Keyword;
import com.ghost.technic.server.card.keyword.KeywordAbility;
import com.ghost.technic.server.counter.Counters;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventBus;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
class MusthicMurmookTest {
private GameActionService actionService;
private GameState gameState;
private Player alice;
private Player bob;
@BeforeEach
void setup() {
gameState = new GameState(new EventBus());
actionService = new GameActionService(gameState);
bob = Player.builder().name("Bob").build();
gameState.setPlayers(alice, bob);
}
@Test
void testMusthicMurmookAffectsKeyCostAndDeals4Damage() {
CreatureCard briarGrubbling = CreatureCard.builder()
.name("Briar Grubbling")
.power(2)
.armor(0)
.traits(List.of("Insect", "Beast"))
.counters(new Counters())
.keywords(Set.of(new Keyword(KeywordAbility.HAZARDOUS, 5)))
.build();
CreatureCard musthicMurmook = CreatureCard.builder()
.name("Musthic Murmook")
.power(3)
.armor(0)
.abilities(List.of(new MusthicMurmookAbility()))
.counters(new Counters())
.build();
alice = Player.builder().name("Alice").build();
alice = new Player(alice) {
@Override
public CreatureCard chooseCreature(List<CreatureCard> options, String reason) {
return briarGrubbling; // target own creature for test
}
};
gameState.setOwner(briarGrubbling, alice);
gameState.setOwner(musthicMurmook, alice);
actionService.playCreature(alice, briarGrubbling, Flank.LEFT);
actionService.playCreature(alice, musthicMurmook, Flank.RIGHT); // should trigger 4 dmg on ally
assertEquals(4, briarGrubbling.getDamage(), "Briar Grubbling should have taken 4 damage from play effect");
assertEquals(1, actionService.calculateKeyCostModifier(alice));
assertEquals(1, actionService.calculateKeyCostModifier(bob));
}
}

View File

@@ -0,0 +1,154 @@
package com.ghost.technic.server.card;
import com.ghost.technic.server.card.effect.impl.RainceFuryheartAbility;
import com.ghost.technic.server.card.effect.impl.WayOfTheBearAbility;
import com.ghost.technic.server.card.keyword.BeforeFightEffect;
import com.ghost.technic.server.card.keyword.Keyword;
import com.ghost.technic.server.card.keyword.KeywordAbility;
import com.ghost.technic.server.card.playable.impl.UpgradeCard;
import com.ghost.technic.server.counter.Counters;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventBus;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class RainceFuryheartAndWayOfTheBearBeforeFightTest {
GameActionService service;
Player active;
Player defenderPlayer;
CreatureCard raince;
CreatureCard defender;
UpgradeCard wayOfTheBear;
@BeforeEach
void setUp() {
service = new GameActionService(new GameState(new EventBus()));
defenderPlayer = Player.builder().name("Bob").build();
// Defender: 2 power, Hazardous 2
defender = CreatureCard.builder()
.name("Hazard Goblin")
.power(2)
.armor(0)
.keywords(new HashSet<>(Set.of(new Keyword(KeywordAbility.HAZARDOUS, 3))))
.counters(new Counters())
.build();
raince = CreatureCard.builder()
.name("Raince Furyheart")
.power(3)
.armor(0)
.keywords(new HashSet<>(Set.of(new Keyword(KeywordAbility.SKIRMISH))))
.counters(new Counters())
.abilities(List.of(new RainceFuryheartAbility()))
.build();
wayOfTheBear = UpgradeCard.builder()
.name("Way of the Bear")
.abilities(List.of(new WayOfTheBearAbility()))
.build();
service.getGameState().setOwner(raince, active);
service.getGameState().setOwner(defender, defenderPlayer);
}
@Test
void testEffectOrder_RainceThenAssaultThenHazardous() {
active = Player.builder().name("Alice").build();
active = new Player(active) {
@Override
public List<BeforeFightEffect> chooseBeforeFightOrder(List<BeforeFightEffect> effects) {
return List.of(
desc(effects, "Exalt the creature Raince Furyheart fights"),
desc(effects, "Assault from Raince Furyheart"),
desc(effects, "Hazardous from Hazard Goblin")
);
}
};
service.playCreature(active, raince, Flank.LEFT);
service.getGameState().setPlayers(active, defenderPlayer);
service.getGameState().setActivePlayer(active);
service.playUpgrade(active, wayOfTheBear, raince);
assertTrue(raince.hasKeyword(KeywordAbility.ASSAULT), "Raince should have Assault after Way of the Bear");
service.fight(raince, defender, active);
assertEquals(1, defender.getCounters().getAmber(), "Defender should be exalted");
assertTrue(defender.isDestroyed(), "Defender should be destroyed by Assault");
assertFalse(raince.isDestroyed(), "Raince should survive");
}
@Test
void testEffectOrder_HazardousFirst() {
active = Player.builder().name("Alice").build();
active = new Player(active) {
@Override
public List<BeforeFightEffect> chooseBeforeFightOrder(List<BeforeFightEffect> effects) {
return List.of(
desc(effects, "Hazardous from Hazard Goblin"),
desc(effects, "Assault from Raince Furyheart"),
desc(effects, "Exalt the creature Raince Furyheart fights")
);
}
};
service.playCreature(active, raince, Flank.LEFT);
service.getGameState().setPlayers(active, defenderPlayer);
service.getGameState().setActivePlayer(active);
service.playUpgrade(active, wayOfTheBear, raince);
service.fight(raince, defender, active);
assertEquals(0, defender.getCounters().getAmber(), "Defender should NOT be exalted if Raince dies first");
assertTrue(raince.isDestroyed(), "Raince should be destroyed by Hazardous before fight");
assertFalse(defender.isDestroyed(), "Defender should survive");
}
@Test
void testEffectOrder_AssaultThenHazardousThenRaince() {
active = Player.builder().name("Alice").build();
active = new Player(active) {
@Override
public List<BeforeFightEffect> chooseBeforeFightOrder(List<BeforeFightEffect> effects) {
return List.of(
desc(effects, "Assault from Raince Furyheart"),
desc(effects, "Hazardous from Hazard Goblin"),
desc(effects, "Exalt the creature Raince Furyheart fights")
);
}
};
service.playCreature(active, raince, Flank.LEFT);
service.getGameState().setPlayers(active, defenderPlayer);
service.getGameState().setActivePlayer(active);
service.playUpgrade(active, wayOfTheBear, raince);
service.fight(raince, defender, active);
assertEquals(0, defender.getCounters().getAmber(), "Exalt should not have resolved because assault would have killed defender first");
assertTrue(defender.isDestroyed(), "Defender should die from Assault");
assertFalse(raince.isDestroyed(), "Raince would survive because the assault would kill defender first");
}
private BeforeFightEffect desc(List<BeforeFightEffect> list, String text) {
return list.stream()
.filter(e -> e.description().equals(text))
.findFirst()
.orElseThrow(() -> new AssertionError("Missing effect: " + text));
}
}

View File

@@ -0,0 +1,69 @@
package com.ghost.technic.server.card;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.effect.impl.ScreeyanAbility;
import com.ghost.technic.server.card.impl.ArchonIdentityCard;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.GameTurnManager;
import com.ghost.technic.server.game.event.EventBus;
import org.junit.jupiter.api.Test;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class ScreeyanTest {
GameActionService actionService;
GameTurnManager turnManager;
// GameState gameState;
Player alice;
Player bob;
@Test
void testScreeyanPreventsOpponentFromChoosingDiscardedHouse() {
// Setup players
alice = Player.builder()
.name("Alice")
.archonIdentityCard(ArchonIdentityCard.builder().houses(Set.of(House.BROBNAR, House.SHADOWS, House.UNTAMED)).build())
.build();
bob = Player.builder()
.name("Bob")
.archonIdentityCard(ArchonIdentityCard.builder().houses(Set.of(House.SANCTUM, House.LOGOS, House.MARS)).build())
.deck(new LinkedList<>(List.of(
CreatureCard.builder().name("Some Mars Creature").house(House.MARS).build()
)))
.build();
var screeyan = CreatureCard.builder()
.name("Screeyan")
.power(5)
.house(House.DIS)
.abilities(List.of(new ScreeyanAbility()))
.build();
GameActionService actionService = new GameActionService(new GameState(new EventBus()));
GameTurnManager turnManager = new GameTurnManager(actionService.getGameState(), actionService);
actionService.getGameState().setPlayers(alice, bob);
actionService.getGameState().setActivePlayer(alice);
actionService.playCreature(alice, screeyan, Flank.LEFT);
// Start Bobs turn
turnManager.endOfTurn(alice);
// turnManager.takeTurn(bob);
// Assert MARS is blocked
assertEquals(actionService.getGameState().getRestrictedHouses(bob), Set.of(House.MARS));
}
}

View File

@@ -0,0 +1,78 @@
package com.ghost.technic.server.card;
import com.ghost.technic.server.card.effect.impl.SibylWaimareAbility;
import com.ghost.technic.server.game.GameTurnManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventBus;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class SibylWaimareTest {
GameActionService gameActionService;
GameState gameState;
Player opponent;
Player controller;
CreatureCard topCreatureCard;
@BeforeEach
void setUp() {
gameState = new GameState(new EventBus());
gameActionService = new GameActionService(gameState);
controller = Player.builder().name("Waimare Controller").build();
opponent = Player.builder().name("Target Player").build();
gameState.setPlayers(controller, opponent);
// Creature that will be exhausted
CreatureCard brobnarCreature = CreatureCard.builder()
.name("Brobnar Bruiser")
.house(House.BROBNAR)
.power(4)
.armor(1)
.ready(true)
.build();
opponent.getPlayArea().getBattleline().addToFlank(brobnarCreature, Flank.LEFT);
gameState.setOwner(brobnarCreature, opponent);
// Deck top card is BROBNAR
topCreatureCard = CreatureCard.builder().name("Top card").house(House.BROBNAR).build();
opponent.getDeck().add(topCreatureCard);
gameState.setOwner(topCreatureCard, opponent);
// Sibyl Waimare in play
CreatureCard sibyl = CreatureCard.builder()
.name("Sibyl Waimare")
.house(House.DIS)
.power(4)
.armor(2)
.abilities(List.of(new SibylWaimareAbility()))
.build();
controller.getPlayArea().getBattleline().addToFlank(sibyl, Flank.RIGHT);
gameState.setOwner(sibyl, controller);
}
@Test
void testSibylWaimareExhaustsOpponentCreaturesOfDiscardedHouse() {
assertTrue(opponent.getPlayArea().getBattleline().getCreatures().getFirst().isReady());
GameTurnManager turnManager = new GameTurnManager(gameState, gameActionService);
turnManager.handleStartOfTurnAbilities(opponent);
CreatureCard brobnarCreature = opponent.getPlayArea().getBattleline().getCreatures().getFirst();
assertFalse(brobnarCreature.isReady(), "Creature of discarded house should be exhausted");
assertEquals(topCreatureCard, opponent.getDiscardPile().getFirst(), "Top card should be discarded");
}
}

View File

@@ -0,0 +1,115 @@
package com.ghost.technic.server.game;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.counter.Counters;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventBus;
import com.ghost.technic.server.game.event.EventType;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Slf4j
public class DealingDamageTest {
private GameActionService gameActionService;
private CreatureCard creature;
@BeforeEach
void setUp() {
gameActionService = new GameActionService(new GameState(new EventBus()));
creature = CreatureCard.builder()
.name("Sacro-Beast")
.house(House.UNTAMED)
.traits(List.of("Mutant", "Beast"))
.power(5)
.armor(2)
.counters(new Counters())
.build();
// log.info(creature.toString());
}
@Test
void testFullArmorAbsorbsAllDamage() {
AtomicBoolean preventedTriggered = new AtomicBoolean(false);
gameActionService.getEventBus().subscribe(EventType.DAMAGE_PREVENTED, event -> {
CreatureCard c = event.get("creature");
int amount = event.get("amount");
assertEquals("Sacro-Beast", c.getName());
assertEquals(2, amount);
preventedTriggered.set(true);
});
gameActionService.dealDamage(creature, 2);
assertEquals(0, creature.getCounters().getDmg(), "No damage should be on the creature");
assertEquals(0, creature.getArmor(), "All Armor should be consumed");
assertTrue(preventedTriggered.get(), "DAMAGE_PREVENTED event should be triggered");
}
@Test
void testPartialArmorPartialDamage() {
AtomicInteger damageTaken = new AtomicInteger();
AtomicInteger damagePrevented = new AtomicInteger();
gameActionService.getEventBus().subscribe(EventType.DAMAGE_PREVENTED, event -> {
damagePrevented.set(event.get("amount"));
});
gameActionService.getEventBus().subscribe(EventType.DAMAGE_TAKEN, event -> {
damageTaken.set(event.get("amount"));
});
gameActionService.dealDamage(creature, 4); // 2 absorbed, 2 to damage
assertEquals(2, creature.getCounters().getDmg(), "Creature should have 2 damage");
assertEquals(0, creature.getArmor(), "Armor should be reduced to 0");
assertEquals(2, damagePrevented.get(), "2 damage should be prevented");
assertEquals(2, damageTaken.get(), "2 damage should be taken");
}
@Test
void testNoArmorTakesAllDamage() {
AtomicInteger damageTaken = new AtomicInteger();
creature.setArmor(0); // no Armor
gameActionService.getEventBus().subscribe(EventType.DAMAGE_TAKEN, event -> {
damageTaken.set(event.get("amount"));
});
gameActionService.dealDamage(creature, 3);
assertEquals(3, creature.getCounters().getDmg(), "All 3 damage should be applied");
assertEquals(3, damageTaken.get(), "DAMAGE_TAKEN should show full amount");
}
@Test
void testZeroDamageStillFiresPreventIfArmor() {
AtomicBoolean preventedTriggered = new AtomicBoolean(false);
gameActionService.getEventBus().subscribe(EventType.DAMAGE_PREVENTED, event -> {
preventedTriggered.set(true);
assertEquals(0, (int) event.get("amount"));
});
gameActionService.dealDamage(creature, 0);
assertEquals(2, creature.getArmor(), "Armor should remain untouched");
assertEquals(0, creature.getCounters().getDmg(), "No damage should be dealt");
assertTrue(preventedTriggered.get(), "Prevented event should still fire with zero dmg");
}
}

View File

@@ -0,0 +1,90 @@
package com.ghost.technic.server.game;
import com.ghost.technic.server.Player;
import com.ghost.technic.server.board.Flank;
import com.ghost.technic.server.card.playable.PlayableCard;
import com.ghost.technic.server.game.event.UsageType;
import com.ghost.technic.server.game.event.UsedEventData;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.ghost.technic.server.card.misc.House;
import com.ghost.technic.server.card.playable.usable.impl.CreatureCard;
import com.ghost.technic.server.game.GameActionService;
import com.ghost.technic.server.game.GameState;
import com.ghost.technic.server.game.event.EventBus;
import com.ghost.technic.server.game.event.EventType;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.*;
@Slf4j
class ReapTest {
private GameActionService gameActionService;
private Player player;
private CreatureCard creature;
@BeforeEach
void setUp() {
gameActionService = new GameActionService(new GameState(new EventBus()));
gameActionService.getGameState().trackRuleOfSix(); // make sure listener is registered
player = Player.builder().name("Test Player").build();
creature = CreatureCard.builder().name("Test Creature").house(House.LOGOS).build();
creature.setReady(true); // simulate creature is ready
}
@Test
void testReapGainsAmberAndExhaustsCreature() {
gameActionService.reap(creature, player);
assertEquals(1, player.getAmber(), "Player should gain 1 amber after reaping");
assertFalse(creature.isReady(), "Creature should be exhausted (not ready) after reaping");
}
@Test
void testReapPublishesReapedEvent() {
AtomicBoolean reapEventTriggered = new AtomicBoolean(false);
gameActionService.getEventBus().subscribe(EventType.USED, event -> {
UsedEventData usedData = event.get("usedEventData");
PlayableCard card = usedData.getCard();
UsageType usage = usedData.getUsageType();
if (usage.equals(UsageType.REAP)) {
reapEventTriggered.set(true);
}
log.info("Card used: {}, Usage type: {}", card.getName(), usage);
});
gameActionService.reap(creature, player);
assertTrue(reapEventTriggered.get(), "REAPED event should have been published");
}
@Test
void testCannotReapIfExhausted() {
creature.setReady(false); // simulate exhausted
gameActionService.reap(creature, player);
assertEquals(0, player.getAmber(), "No amber should be gained if creature is exhausted");
assertFalse(creature.isReady(), "Creature should remain exhausted");
}
@Test
void testReapLimitedByRuleOfSix() {
gameActionService.playCreature(player, creature, Flank.RIGHT);
// Attempt to reap 6 times, forcefully readying the creature each time, only 5 successful reap should fire
for (int i = 0; i < 6; i++) {
creature.setReady(true);
gameActionService.reap(creature, player);
}
assertTrue(creature.isReady(), "Creature should not exhaust since it was played once and reaped 5 times from rule of six");
assertEquals(5, player.getAmber(), "Player should gain 5 amber after 5 legal reaps");
}
}