package com.cardshifter.core.game; import java.io.File; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; import java.util.Set; import java.util.concurrent.ScheduledExecutorService; import java.util.function.Supplier; import java.util.stream.Stream; import com.cardshifter.api.config.PlayerConfig; import com.cardshifter.api.outgoing.*; import com.cardshifter.modapi.base.*; import com.cardshifter.modapi.resources.ResourceViewUpdate; import net.zomis.cardshifter.ecs.EntitySerialization; import net.zomis.cardshifter.ecs.config.ConfigComponent; import org.apache.log4j.LogManager; import org.apache.log4j.Logger; import com.cardshifter.ai.FakeAIClientTCG; import com.cardshifter.api.ClientIO; import com.cardshifter.api.both.ChatMessage; import com.cardshifter.api.both.PlayerConfigMessage; import com.cardshifter.api.incoming.RequestTargetsMessage; import com.cardshifter.api.incoming.UseAbilityMessage; import com.cardshifter.core.replays.ReplayRecordSystem; import com.cardshifter.modapi.actions.ActionComponent; import com.cardshifter.modapi.actions.ActionPerformEvent; import com.cardshifter.modapi.actions.Actions; import com.cardshifter.modapi.actions.ECSAction; import com.cardshifter.modapi.actions.TargetSet; import com.cardshifter.modapi.ai.AIComponent; import com.cardshifter.modapi.ai.AISystem; import com.cardshifter.modapi.ai.CardshifterAI; import com.cardshifter.modapi.cards.CardComponent; import com.cardshifter.modapi.cards.ZoneChangeEvent; import com.cardshifter.modapi.cards.ZoneComponent; import com.cardshifter.modapi.events.EntityRemoveEvent; import com.cardshifter.modapi.events.GameOverEvent; import com.cardshifter.modapi.resources.ResourceValueChange; import com.cardshifter.modapi.resources.Resources; /** * Extends ServerGame which is primarily a state manager. * This initializes the ECSGame and accepts the ECSMod. * * @author Simon Forsberg */ public class TCGGame extends ServerGame { private static final Logger logger = LogManager.getLogger(TCGGame.class); private final ComponentRetriever<CardComponent> card = ComponentRetriever.retreiverFor(CardComponent.class); private ComponentRetriever<PlayerComponent> playerData = ComponentRetriever.retreiverFor(PlayerComponent.class); /** * Supplied as an argument to the initialization method */ private final ECSMod mod; private final Supplier<ScheduledExecutorService> aiExecutor; private final String modName; /** * * @param aiExecutor AI action scheduler * @param name Mod name * @param id The game id * @param mod The mod that the game will run */ public TCGGame(Supplier<ScheduledExecutorService> aiExecutor, String name, int id, ECSMod mod) { super(id, new ECSGame()); this.modName = name; this.aiExecutor = aiExecutor; this.mod = mod; } /** * Gets the card entity for the card, then sends a ZoneChangeMessage to all clients. * Checks if the player has knowledge of the zone, and sends the real card data if so * * @param event The ZoneChangeEvent object */ private void zoneChange(ZoneChangeEvent event) { Entity cardEntity = event.getCard(); int source = -1; if (event.getSource() != null) { source = event.getSource().getZoneId(); } for (ClientIO io : this.getPlayers()) { Entity player = playerFor(io); boolean sourceKnown = false; if (event.getSource() != null) { sourceKnown = event.getSource().isKnownTo(player); } io.sendToClient(new ZoneChangeMessage(event.getCard().getId(), source, event.getDestination().getZoneId())); if (event.getDestination().isKnownTo(player) && !sourceKnown) { sendRealCardData(io, event.getDestination().getZoneId(), cardEntity); } } } /** * Sends a CardInfoMessage to the targeted player. * This is sent in addition to zoneChange for zones the player has knowledge of * * @param io Target client * @param zoneId Zone that the card is in * @param cardEntity The card Entity object */ private void sendRealCardData(ClientIO io, int zoneId, Entity cardEntity) { io.sendToClient(new CardInfoMessage(zoneId, cardEntity.getId(), infoMap(cardEntity))); } /** * Sends an EntityRemoveMessage for the supplied event * * @param event The EntityRemove event */ private void remove(EntityRemoveEvent event) { this.send(new EntityRemoveMessage(event.getEntity().getId())); } /** * Send a message to all players that should be informed about a change to an entity * * @param entity The changed entity * @param update The update message */ private void broadcast(Entity entity, UpdateMessage update) { if (card.has(entity)) { CardComponent cardData = card.get(entity); for (ClientIO io : this.getPlayers()) { Entity player = playerFor(io); if (cardData.getCurrentZone().isKnownTo(player)) { io.sendToClient(update); } } } else { // Player, Zone, or Game this.send(update); } } /** * Broadcast information about the value returned by a resource for an entity. * * @param event The ResourceViewUpdate event */ private void broadcast(ResourceViewUpdate event) { if (game.getGameState() == ECSGameState.NOT_STARTED) { // let the most information be sent when actually starting the game return; } Entity entity = event.getEntity(); UpdateMessage updateEvent = new UpdateMessage(entity.getId(), event.getResource().toString(), event.getNewValue()); broadcast(entity, updateEvent); } /** * If the event is a for a player, zone, or game, an UpdateMessage is sent to all players. * For cards it is only sent to players who have knowledge of the card zone * * @param event The ResourceValueChange event */ private void broadcast(ResourceValueChange event) { if (game.getGameState() == ECSGameState.NOT_STARTED) { // let the most information be sent when actually starting the game return; } // Entity entity = event.getEntity(); // UpdateMessage updateEvent = new UpdateMessage(entity.getId(), event.getResource().toString(), event.getNewValue()); // broadcast(entity, updateEvent); } /** * Sends a list of target entities based on the action supplied in the message * * @param message The RequestTargetsMessage object * @param client The client requesting the targets */ public void informAboutTargets(RequestTargetsMessage message, ClientIO client) { ECSAction action = findAction(message.getId(), message.getAction()); TargetSet targetAction = action.getTargetSets().get(0); List<Entity> targets = targetAction.findPossibleTargets(); int[] targetIds = targets.stream().mapToInt(e -> e.getId()).toArray(); client.sendToClient(new AvailableTargetsMessage(message.getId(), message.getAction(), targetIds, targetAction.getMin(), targetAction.getMax())); } /** * Look for a specific action on a specific entity * * @param entityId Target entity to search for actions * @param actionId Action to look for on the entity * @return The ECSAction object */ public ECSAction findAction(int entityId, String actionId) { Entity entity = Objects.requireNonNull(game.getEntity(entityId), "Entity " + entityId + " not found"); ECSAction action = Actions.getAction(entity, actionId); return Objects.requireNonNull(action, "Action " + actionId + " not found on entity " + entityId); } /** * Checks the game state, client, and action for validity. * If valid, finds the action of the message, finds the available targets, and sends them to the client * * @param message The UseAbilityMessage object * @param client The client object who sent the move */ public void handleMove(UseAbilityMessage message, ClientIO client) { if (this.isGameOver()) { logger.info("Ignoring move because game has ended: " + message + " from " + client); return; } if (!this.getPlayers().contains(client)) { throw new IllegalArgumentException("Client is not in this game: " + client); } ECSAction action = findAction(message.getId(), message.getAction()); if (!action.getTargetSets().isEmpty()) { TargetSet targetAction = action.getTargetSets().get(0); targetAction.clearTargets(); for (int target : message.getTargets()) { targetAction.addTarget(game.getEntity(target)); } } Entity performer = playerFor(client); boolean allowed = action.perform(performer); if (!allowed) { client.sendToClient(new ServerErrorMessage("Action not allowed: " + action)); } sendAvailableActions(); } /** * * @param io The target client * @return The index of the client in this object */ public Entity playerFor(ClientIO io) { int index = this.getPlayers().indexOf(io); if (index < 0) { throw new IllegalArgumentException(io + " is not a valid player in this game"); } return getPlayer(index); } /** * * @param index The index to search for * @return The player at that index */ private Entity getPlayer(int index) { List<Entity> players = game.findEntities(entity -> entity.hasComponent(PlayerComponent.class) && entity.getComponent(PlayerComponent.class).getIndex() == index); if (players.size() != 1) { throw new IllegalStateException("Found " + players.size() + " results for entities with Player index " + index); } return players.get(0); } /** * Initializes configurations, starts the ECSGame, and sets up the AI */ @Override protected void onStart() { DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH-mm-ss").withZone(ZoneId.systemDefault()); String time = formatter.format(Instant.now()); File directory = new File("replays", modName); directory.mkdirs(); game.addSystem(new ReplayRecordSystem(game, modName, new File(directory, "replay-" + getId() + "-" + time + ".json"))); game.addSystem(new ECSSystem() { @Override public void startGame(ECSGame game) { game.getEvents().registerHandlerBefore(TCGGame.this, ActionPerformEvent.class, action -> { UseAbilityMessage useAbilityMessage = new UseAbilityMessage(getId(), action.getEntity().getId(), action.getAction().getName(), action.getAction().getAllTargets().mapToInt(e -> e.getId()).toArray()); send(useAbilityMessage.withPerformer(action.getPerformer().getId())); }); } }); if (!preStartForConfiguration()) { this.startECSGame(); this.setupAIPlayers(); // PlayerComponents needs to be setup first AISystem.call(game); } } /** * Pre-start the game to ask for configuration * * @return */ public boolean preStartForConfiguration() { mod.declareConfiguration(game); if (this.isConfigNeeded()) { this.setupAIPlayers(); this.requestPlayerConfig(); return true; } return false; } /** * Listener for when a player is eliminated from the game * @param event Event about the elimination */ private void playerEliminated(PlayerEliminatedEvent event) { String winStatus = event.isDeclaredWinner() ? "won" : "lost"; PlayerComponent player = event.getEntity().getComponent(PlayerComponent.class); this.send(new PlayerEliminatedMessage(event.getEntity().getId(), event.isDeclaredWinner(), event.getResultPosition())); this.sendChat(player.getName() + " " + winStatus + " game " + getId()); } public void sendChat(String message) { this.send(new ChatMessage(0, "Server", message)); } /** * Sets up the game based on the mod. * Events are registered to the game which handle a class and call a method. * Sends the initial available actions for the game. */ private void startECSGame() { mod.setupGame(game); game.getEvents().registerHandlerAfter(this, ResourceValueChange.class, this::broadcast); game.getEvents().registerHandlerAfter(this, ResourceViewUpdate.class, this::broadcast); game.getEvents().registerHandlerAfter(this, ZoneChangeEvent.class, this::zoneChange); game.getEvents().registerHandlerAfter(this, EntityRemoveEvent.class, this::remove); game.getEvents().registerHandlerAfter(this, PlayerEliminatedEvent.class, this::playerEliminated); game.getEvents().registerHandlerAfter(this, GameOverEvent.class, event -> this.endGame()); AISystem.setup(game, aiExecutor.get()); game.addSystem(game -> game.getEvents().registerHandlerAfter(this, ActionPerformEvent.class, event -> this.sendAvailableActions())); game.startGame(); this.getPlayers().stream().forEach(pl -> { Entity playerEntity = playerFor(pl); PlayerComponent plData = playerEntity.get(playerData); plData.setName(pl.getName()); this.send(new PlayerMessage(playerEntity.getId(), plData.getIndex(), plData.getName(), Resources.map(playerEntity))); }); this.game.findEntities(e -> true).stream() .flatMap(e -> e.getSuperComponents(ZoneComponent.class).stream()) .sorted(Comparator.comparingInt(ZoneComponent::getZoneId)) .forEach(this::sendZone); this.sendAvailableActions(); } /** * Sends a request to players to setup player-specific configuration (special powers, decks, etc.) * * @return True if a request for player-specific configuration has been sent, false if no additional configuration is required. */ private boolean requestPlayerConfig() { Set<Entity> configEntities = game.getEntitiesWithComponent(ConfigComponent.class); boolean sent = false; for (ClientIO io : getPlayers()) { Entity playerEntity = playerFor(io); if (configEntities.contains(playerEntity)) { Map<String, PlayerConfig> configs = playerEntity.getComponent(ConfigComponent.class).getConfigs(); configs.values().forEach(PlayerConfig::beforeSend); PlayerConfigMessage configMessage = new PlayerConfigMessage(getId(), modName, configs); io.sendToClient(configMessage); if (io instanceof FakeAIClientTCG) { FakeAIClientTCG aiClient = (FakeAIClientTCG) io; CardshifterAI ai = aiClient.getAI(); ai.configure(playerEntity, playerEntity.getComponent(ConfigComponent.class)); playerEntity.getComponent(ConfigComponent.class).setConfigured(true); } else { sent = true; } } } return sent; } /** * If a ClientIO in the game is an AI Client, the AI is initialized to its preset AI level */ private void setupAIPlayers() { for (ClientIO io : this.getPlayers()) { if (io instanceof FakeAIClientTCG) { FakeAIClientTCG aiClient = (FakeAIClientTCG) io; Entity player = playerFor(io); AIComponent aiComponent = new AIComponent(aiClient.getAI()); aiComponent.setDelay(2000); player.addComponent(aiComponent); logger.info("AI is configured for " + player); } } } /** * Looks at all Clients, resets their actions, then gets all actions for each and sends them. */ private void sendAvailableActions() { for (ClientIO io : this.getPlayers()) { io.sendToClient(new ResetAvailableActionsMessage()); if (game.isGameOver()) { continue; } Entity player = playerFor(io); getAllActions(game).filter(action -> action.isAllowed(player)) .forEach(action -> io.sendToClient(new UsableActionMessage(action.getOwner().getId(), action.getName(), !action.getTargetSets().isEmpty()))); } } /** * * @param game The game to search for actions * @return A stream of action components for all entities in the game */ private static Stream<ECSAction> getAllActions(ECSGame game) { return game.getEntitiesWithComponent(ActionComponent.class) .stream() .flatMap(entity -> entity.getComponent(ActionComponent.class) .getECSActions().stream()); } /** * Sends the zone to all players. If the zone is known, also sends the cards. * * @param zone The zone component to send */ private void sendZone(ZoneComponent zone) { for (ClientIO io : this.getPlayers()) { Entity player = playerFor(io); io.sendToClient(constructZoneMessage(zone, player)); if (zone.isKnownTo(player)) { zone.forEach(card -> this.sendCard(io, card)); } } } /** * Zones are treated differently when they are known to the target player. * * @param zone The zone component object * @param player The target player for the message * @return A zone message constructed based on the zone component object properties. */ private ZoneMessage constructZoneMessage(ZoneComponent zone, Entity player) { return new ZoneMessage(zone.getZoneId(), zone.getName(), zone.getOwner().getId(), zone.size(), zone.isKnownTo(player), zone.stream().mapToInt(e -> e.getId()).toArray()); } /** * A CardInfoMesage is created and sent to the target client. * * @param io The target client * @param card The card entity to send */ private void sendCard(ClientIO io, Entity card) { CardComponent cardData = card.getComponent(CardComponent.class); io.sendToClient(new CardInfoMessage(cardData.getCurrentZone().getZoneId(), card.getId(), infoMap(card))); } /** * * @param entity The entity to map * @return A map of the target entity */ private Map<String, Object> infoMap(Entity entity) { return EntitySerialization.serialize(entity); } /** * Set the player configuration for a player and then if no more configuration is needed, start the ECSGame. * * @param message The PlayerConfigMessage object * @param client The client that sent the config */ public void incomingPlayerConfig(PlayerConfigMessage message, ClientIO client) { Entity player = playerFor(client); ConfigComponent config = player.getComponent(ConfigComponent.class); for (Entry<String, PlayerConfig> entry : message.getConfigs().entrySet()) { config.addConfig(entry.getKey(), entry.getValue()); logger.info("Incoming player config for " + player + ": " + entry.getValue()); } config.setConfigured(true); checkStartGame(); } public void checkStartGame() { if (!this.isConfigNeeded()) { startECSGame(); } } /** * * @return Returns true if any of the ConfigComponents are not configured */ public boolean isConfigNeeded() { Set<Entity> configEntities = game.getEntitiesWithComponent(ConfigComponent.class); return configEntities.stream().map(e -> e.getComponent(ConfigComponent.class)).anyMatch(config -> !config.isConfigured()); } }