com.spankingrpgs.scarletmoon.loader.EventLoader.java Source code

Java tutorial

Introduction

Here is the source code for com.spankingrpgs.scarletmoon.loader.EventLoader.java

Source

/*
 * CrimsonGlow is an adult computer roleplaying game with spanking content.
 * Copyright (C) 2015 Andrew Russell
 *
 *      This program is free software: you can redistribute it and/or modify
 *      it under the terms of the GNU General Public License as published by
 *      the Free Software Foundation, either version 3 of the License, or
 *      (at your option) any later version.
 *
 *      This program is distributed in the hope that it will be useful,
 *      but WITHOUT ANY WARRANTY; without even the implied warranty of
 *      MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *      GNU General Public License for more details.
 *
 *      You should have received a copy of the GNU General Public License
 *      along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package com.spankingrpgs.scarletmoon.loader;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import com.spankingrpgs.model.GameState;
import com.spankingrpgs.model.loader.Loader;
import com.spankingrpgs.model.music.MusicMap;
import com.spankingrpgs.model.story.Event;
import com.spankingrpgs.model.story.EventDescription;
import com.spankingrpgs.model.story.EventFactory;
import com.spankingrpgs.model.story.TextParser;
import com.spankingrpgs.model.story.TextResolver;
import com.spankingrpgs.model.story.UniversalEvent;
import com.spankingrpgs.util.CollectionUtils;
import javafx.scene.media.Media;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 * Responsible for loading events. Events are the story events in the game. The JSON format is:
 *
 * {
 *  "name": "event",
 *  "text" : "Imma do lots of things.",
 *  "do": ["addKeyword(keyword)", "decrementStatistic(strength, 2)"]
 *  "automatic choices": {
 *      "hasKeyword(keyword)": "otherEvent",
 *      "isFemale()": "femaleEvent",
 *  },
 *  "choices": {
 *      "Punch [himher(alexandra)] in the face": punchEvent",
 *      "Kiss [himher(alexandra)[ on the lips": kissEvent"
 *  }
 * }
 *
 *  and in YAML:
 *
 *- name: event
 *  text : |
 *      Imma do lots of things.
 *  do: [addKeyword(keyword), decrementStatistic(strength, 2)]
 *  automatic choices:
 *      hasKeyword(keyword): otherEvent,
 *      isFemale(): femaleEvent,
 *  choices:
 *      Punch [himher(alexandra)] in the face": punchEvent
 *      Kiss [himher(alexandra)[ on the lips": kissEvent
 */
public class EventLoader implements Loader {
    private static final Logger LOG = Logger.getLogger(EventLoader.class.getName());

    private static final ObjectMapper JSON_PARSER = new ObjectMapper();

    private static final Map<String, Function<List<String>, Predicate<GameState>>> predicateBuilders = new HashMap<>();
    private static Map<String, Function<List<String>, Consumer<TextResolver>>> stateCommands = new HashMap<>();

    private final EventFactory eventFactory;
    private final ObjectMapper parser;
    private final String gameRoot;

    /**
     * This should be true in actual application. This exists solely so that I can turn off loading music for
     * tests, since JavaFX doesn't let me build a MediaPlayer without starting the application, which I can't do through
     * a unit test.
     */
    private static boolean loadMusic = true;

    /**
     * Registers the specified Predicate builder under the specified name.
     *
     * @param name  The name of the predicate
     *
     * @param builder  A function that maps a list of arguments to the predicate (as Strings) to a Predicate on a
     * GameState
     */
    public static void registerPredicate(String name, Function<List<String>, Predicate<GameState>> builder) {
        predicateBuilders.put(name, builder);
    }

    /**
     * Registers the specified state modifying command builder to the specified name.
     *
     * @param name  The name to register the dynamic text function builder under
     * @param builder  The state modifying command builder to be registered
     */
    public static void registerCommandBuilder(String name, Function<List<String>, Consumer<TextResolver>> builder) {
        stateCommands.put(name, builder);
    }

    /**
     * Builds an object capable of parsing a file format parseable by Jackson into a GameEvent.
     *
     * @param eventFactory  The object that builds events
     * @param parser  The parser to use to parse character files
     * @param gameRoot  The root directory of the game
     */
    public EventLoader(EventFactory eventFactory, ObjectMapper parser, String gameRoot) {
        this.eventFactory = eventFactory;
        this.parser = parser;
        this.gameRoot = gameRoot;
    }

    /**
     * Builds an object capable of parsing a file format parseable by Jackson into a GameEvent.
     * Defaults the {@code eventFactory} to {@code UniversalEvent::new}
     *
     * @param parser  The parser to use to hydrate event files into game events
     * @param gameRoot  The root directory of the game
     */
    public EventLoader(ObjectMapper parser, String gameRoot) {
        this(UniversalEvent::new, parser, gameRoot);
    }

    /**
     * Builds an object capable of parsing JSON files into Events.
     * Defaults the {@code eventFactory} to {@code UniversalEvent::new} and the {@code parser} to a standard
     * Jackson ObjectMapper that allows comments.
     *
     * @param gameRoot  The root of the game
     */
    public EventLoader(String gameRoot) {
        this(UniversalEvent::new, JSON_PARSER, gameRoot);
        JSON_PARSER.configure(JsonParser.Feature.ALLOW_COMMENTS, true);
    }

    @Override
    public void load(Collection<String> eventData, GameState state) {
        eventData.stream().forEach(datum -> loadEvent(datum, state));
    }

    /**
     * Parses the passed JSON into an Event, and loads it into the specified state.
     *
     * @param eventData  The JSON representing the Event
     * @param state  The state to load the event into
     */
    private void loadEvent(String eventData, GameState state) {
        try {
            List<JsonNode> eventList = parser.readValue(eventData, new TypeReference<List<JsonNode>>() {
            });
            for (JsonNode eventJson : eventList) {
                verifyFields(eventJson);
                String name = eventJson.get("name").asText();
                JsonNode eventBody = eventJson.get("text");
                String newLine = parser == JSON_PARSER ? TextParser.NEW_LINE_MARKER : "\n";
                Event event = eventFactory.build(
                        eventBody == null ? EventDescription.EMPTY_BODY
                                : TextParser.parse(eventBody.asText().trim().replace("''", "'"), newLine),
                        hydrateCommands(eventJson.get("do")),
                        hydrateAutomatedChoices(eventJson.get("automatic choices")),
                        hydrateChoices(eventJson.get("choices")),
                        loadMusic ? hydrateMusic(eventJson.get("music")) : null);
                state.addEvent(name, event);
            }
        } catch (IOException e) {
            LOG.log(Level.SEVERE, e.getMessage());
            throw new IllegalArgumentException(e);
        }
    }

    /**
     * Constructs a media object from a music file name, and stores it in the {@link MusicMap}.
     *
     * @param music  The music file to turn into media
     *
     * @return The name of the song that was just stored
     */
    private String hydrateMusic(JsonNode music) {
        if (music == null) {
            return null;
        }
        String musicName = music.asText();
        MusicMap musicMap = MusicMap.INSTANCE;
        if (musicMap.containsKey(musicName)) {
            return musicName;
        }
        //ogg is not supported
        List<String> types = Arrays.asList(".mp3", ".wav");
        Optional<String> musicFileName = types.stream()
                .map(type -> Paths.get(gameRoot, "data", "music", music.asText() + type)).map(Path::toString)
                .map(File::new).filter(File::exists).map(File::toURI).map(URI::toString).findFirst();

        if (!musicFileName.isPresent()) {
            LOG.warning(String.format("Music %s not found.", musicFileName));
            return musicName;
        }
        Media media = new Media(musicFileName.get());
        musicMap.put(musicName, media);
        return musicName;
    }

    /**
     * Given a node containing state modifying commands that need to be executed, constructs a Consumer
     * that applies all of those changes in sequence.
     *
     * @param commands  The commands to execute at the end of the event, if null then returns
     * {@link UniversalEvent#NO_CHANGES}
     *
     * @return  The consumer that executes all of the commands when given a {@link GameState}
     */
    private Consumer<TextResolver> hydrateCommands(JsonNode commands) {
        if (commands == null) {
            return UniversalEvent.NO_CHANGES;
        }

        List<Consumer<TextResolver>> hydratedCommands = new ArrayList<>();
        for (JsonNode command : commands) {
            Matcher matcher = Pattern.compile(TextParser.FUNCTION_REGEX).matcher(command.asText());
            if (!matcher.matches()) {
                String msg = String.format("%s is not a valid function expression.", command.asText());
                LOG.log(Level.SEVERE, msg);
                throw new IllegalArgumentException(msg);
            }
            String functionName = matcher.group(TextParser.FUNCTION_NAME_GROUP);
            List<String> functionArgs = Arrays.stream(matcher.group(TextParser.FUNCTION_ARGUMENTS_GROUP).split(","))
                    .map(String::trim).collect(Collectors.toList());
            hydratedCommands.add(CollectionUtils.getValue(stateCommands, functionName).apply(functionArgs));
        }
        return gameState -> hydratedCommands.stream().forEach(command -> command.accept(gameState));
    }

    /**
     * Hydrates the character's choices. These are choices the player makes explicitly to move through the Event
     * tree.
     *
     * @param choices  The JSON Object containing the available choices
     *
     * @return A mapping from the text displayed to the player to the names of the Events that describe the consequences
     * of making a particular choice
     */
    private LinkedHashMap<EventDescription, String> hydrateChoices(JsonNode choices) {
        if (choices == null) {
            return new LinkedHashMap<>();
        }
        Iterator<String> choicesText = choices.fieldNames();
        LinkedHashMap<EventDescription, String> hydratedChoices = new LinkedHashMap<>();
        String newLine = parser == JSON_PARSER ? TextParser.NEW_LINE_MARKER : "\n";
        while (choicesText.hasNext()) {
            String playerChoiceText = choicesText.next();
            hydratedChoices.put(TextParser.parse(playerChoiceText, newLine),
                    choices.get(playerChoiceText).asText());
        }
        return hydratedChoices;
    }

    private LinkedHashMap<Predicate<GameState>, String> hydrateAutomatedChoices(JsonNode automaticChoices) {
        if (automaticChoices == null) {
            return new LinkedHashMap<>();
        }
        Iterator<String> predicateStrings = automaticChoices.fieldNames();
        LinkedHashMap<Predicate<GameState>, String> hydratedAutomatedChoices = new LinkedHashMap<>();
        while (predicateStrings.hasNext()) {
            String predicateString = predicateStrings.next();
            Predicate<GameState> predicate = parsePredicateString(predicateString);
            hydratedAutomatedChoices.put(predicate, automaticChoices.get(predicateString).asText());
        }
        return hydratedAutomatedChoices;
    }

    /**
     * Given a string representation of a predicate, returns the actual predicate. This method assumes that all the
     * predicates are prefix, and not nested.
     *
     * @param predicate  The function string to be hydrated
     *
     * @return The appropriate predicate that takes a GameState
     *
     * @throws IllegalArgumentException if {@code function} is not the name of a dynamic text function, or the
     * function is malformed.
     */
    private Predicate<GameState> parsePredicateString(String predicate) {
        predicate = predicate.trim();
        if (predicate.contains("&&")) {
            return Arrays.stream(predicate.split("&&")).map(this::parsePredicateString).reduce(Predicate::and)
                    .get();
        }
        if (predicate.toLowerCase().equals("true")) {
            return ignored -> true;
        }
        Matcher prefixFunctionMatcher = Pattern.compile(TextParser.FUNCTION_REGEX).matcher(predicate);
        if (!prefixFunctionMatcher.matches()) {
            String msg = String.format("Malformed predicate: %s", predicate);
            LOG.log(Level.SEVERE, msg);
            throw new IllegalArgumentException(msg);
        }

        String predicateName = prefixFunctionMatcher.group(TextParser.FUNCTION_NAME_GROUP);
        String predicateArguments = prefixFunctionMatcher.group(TextParser.FUNCTION_ARGUMENTS_GROUP);
        Function<List<String>, Predicate<GameState>> predicateBuilder = predicateBuilders.get(predicateName);
        if (predicateBuilder == null) {
            String msg = String.format("No predicate with the name %s exists.", predicateName);
            LOG.log(Level.SEVERE, msg);
            throw new IllegalArgumentException(msg);
        }

        return predicateBuilder
                .apply(Arrays.stream(predicateArguments.split(",")).map(String::trim).collect(Collectors.toList()));
    }

    private void verifyFields(JsonNode eventData) {
        List<String> missingFields = new ArrayList<>();

        if (eventData.get("name") == null) {
            missingFields.add("name");
        }

        if (!missingFields.isEmpty()) {
            String msg = String.format("Character %s is missing fields:\n%s", eventData,
                    String.join("\n", missingFields));
            LOG.log(Level.SEVERE, msg);
            throw new IllegalArgumentException(msg);
        }
    }
}