Skip to content

Commit

Permalink
Unit test dialogue validation
Browse files Browse the repository at this point in the history
  • Loading branch information
Pyrofab committed Oct 30, 2023
1 parent deefd73 commit 9b8dcbf
Show file tree
Hide file tree
Showing 14 changed files with 308 additions and 139 deletions.
12 changes: 12 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ dependencies {

// Test dependencies
modImplementation(libs.elmendorf)

testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
testImplementation "net.fabricmc:fabric-loader-junit:${project.loader_version}"
}

test {
useJUnitPlatform()
maxHeapSize = '1G'
workingDir('run')
testLogging {
events("passed", "skipped", "failed")
}
}

processResources {
Expand Down
Empty file modified gradlew
100644 → 100755
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,9 @@
import net.minecraft.screen.ScreenHandlerType;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.util.Identifier;
import net.minecraft.world.World;
import org.ladysnake.blabber.Blabber;
import org.ladysnake.blabber.DialogueAction;
import org.ladysnake.blabber.impl.common.machine.DialogueStateMachine;
import org.ladysnake.blabber.impl.common.model.DialogueTemplate;
import org.ladysnake.blabber.impl.common.packets.ChoiceAvailabilityPacket;
import org.ladysnake.blabber.impl.common.packets.DialogueListPacket;
import org.ladysnake.blabber.impl.common.packets.SelectedDialogueStatePacket;
Expand All @@ -60,7 +58,6 @@ public final class BlabberRegistrar implements EntityComponentInitializer {
return new DialogueScreenHandler(syncId, dialogue, interlocutor.orElse(null));
}));
public static final Identifier DIALOGUE_ACTION = Blabber.id("dialogue_action");
public static final RegistryKey<Registry<DialogueTemplate>> DIALOGUE_REGISTRY_KEY = RegistryKey.ofRegistry(Blabber.id("dialogues"));
public static final RegistryKey<Registry<Codec<? extends DialogueAction>>> ACTION_REGISTRY_KEY = RegistryKey.ofRegistry(Blabber.id("dialogue_actions"));
public static final Registry<Codec<? extends DialogueAction>> ACTION_REGISTRY = FabricRegistryBuilder.from(
new SimpleRegistry<>(ACTION_REGISTRY_KEY, Lifecycle.stable(), false)
Expand Down Expand Up @@ -92,10 +89,6 @@ public static void init() {
});
}

public static Optional<DialogueTemplate> getDialogueTemplate(World world, Identifier id) {
return world.getRegistryManager().get(DIALOGUE_REGISTRY_KEY).getOrEmpty(id);
}

@Override
public void registerEntityComponentFactories(EntityComponentFactoryRegistry registry) {
registry.registerForPlayers(PlayerDialogueTracker.KEY, PlayerDialogueTracker::new, RespawnCopyStrategy.ALWAYS_COPY);
Expand Down
91 changes: 14 additions & 77 deletions src/main/java/org/ladysnake/blabber/impl/common/DialogueLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,17 @@
import net.minecraft.util.Identifier;
import net.minecraft.util.profiler.Profiler;
import org.ladysnake.blabber.Blabber;
import org.ladysnake.blabber.impl.common.model.ChoiceResult;
import org.ladysnake.blabber.impl.common.model.DialogueChoice;
import org.ladysnake.blabber.impl.common.model.DialogueState;
import org.ladysnake.blabber.impl.common.model.DialogueTemplate;
import org.ladysnake.blabber.impl.common.validation.DialogueLoadingException;
import org.ladysnake.blabber.impl.common.validation.DialogueValidator;
import org.ladysnake.blabber.impl.common.validation.ValidationResult;

import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.util.ArrayDeque;
import java.util.Collection;
import java.util.Deque;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
Expand All @@ -71,82 +68,27 @@ public CompletableFuture<Map<Identifier, DialogueTemplate>> load(ResourceManager
manager.findResources(BLABBER_DIALOGUES_PATH, (res) -> res.getPath().endsWith(".json")).forEach((location, resource) -> {
try (Reader in = new InputStreamReader(resource.getInputStream())) {
JsonObject jsonObject = GSON.fromJson(in, JsonObject.class);
Identifier id = new Identifier(location.getNamespace(), location.getPath().substring(BLABBER_DIALOGUES_PATH.length() + 1));
Identifier id = new Identifier(location.getNamespace(), location.getPath().substring(BLABBER_DIALOGUES_PATH.length() + 1, location.getPath().length() - 5));
DialogueTemplate dialogue = DialogueTemplate.CODEC.parse(JsonOps.INSTANCE, jsonObject).getOrThrow(false, message -> Blabber.LOGGER.error("(Blabber) Could not parse dialogue file from {}: {}", location, message));

if (validateStructure(id, dialogue)) {
data.put(id, dialogue);
ValidationResult result = DialogueValidator.validateStructure(dialogue);
// TODO GIVE ME PATTERN MATCHING IN SWITCHES
if (result instanceof ValidationResult.Error error) {
Blabber.LOGGER.error("(Blabber) Could not validate dialogue {}: {}", id, error.message());
throw new DialogueLoadingException("Could not validate dialogue file from " + location);
} else if (result instanceof ValidationResult.Warnings warnings) {
Blabber.LOGGER.warn("(Blabber) Dialogue {} had warnings: {}", id, warnings.message());
}

data.put(id, dialogue);
} catch (IOException | JsonParseException e) {
Blabber.LOGGER.error("(Blabber) Could not read dialogue file from {}", location, e);
throw new IllegalStateException(e);
throw new DialogueLoadingException("Could not read dialogue file from " + location, e);
}
});
return data;
}, executor);
}

private static boolean validateStructure(Identifier id, DialogueTemplate dialogue) {
Map<String, Map<String, Reachability>> parents = new HashMap<>();
Deque<String> waitList = new ArrayDeque<>();
Map<String, Reachability> unvalidated = new HashMap<>();

for (Map.Entry<String, DialogueState> state : dialogue.states().entrySet()) {
if (state.getValue().type().equals(ChoiceResult.END_DIALOGUE)) {
waitList.add(state.getKey());
} else if (dialogue.states().get(state.getKey()).choices().isEmpty()) {
Blabber.LOGGER.error("(Blabber) {}#{} has no available choices but is not an end state", id, state.getKey());
return false;
} else {
unvalidated.put(state.getKey(), Reachability.NONE);

for (DialogueChoice choice : state.getValue().choices()) {
parents.computeIfAbsent(choice.next(), n -> new HashMap<>()).put(
state.getKey(),
choice.condition().isPresent() ? Reachability.CONDITIONAL : Reachability.PROVEN
);
}
}
}

while (!waitList.isEmpty()) {
String state = waitList.pop();
Map<String, Reachability> stateParents = parents.get(state);

if (stateParents != null) {
for (var parent : stateParents.entrySet()) {
Reachability reachability = unvalidated.get(parent.getKey());

if (reachability != null) { // leave it alone if it was already validated through another branch
if (reachability == Reachability.NONE) { // only check once
waitList.add(parent.getKey());
}

switch (parent.getValue()) {
case PROVEN -> unvalidated.remove(parent.getKey());
case CONDITIONAL -> unvalidated.put(parent.getKey(), Reachability.CONDITIONAL);
default -> throw new IllegalStateException("Unexpected parent-child reachability " + parent.getValue());
}
}
}
} // else, state is unreachable - we log that in the next part
}

for (var bad : unvalidated.entrySet()) {
if (!Objects.equals(bad.getKey(), dialogue.start()) && !parents.containsKey(bad.getKey())) {
// Unreachable states do not cause infinite loops, but we still want to be aware of them
Blabber.LOGGER.warn("(Blabber) {}#{} is unreachable", id, bad.getKey());
} else if (bad.getValue() == Reachability.CONDITIONAL) {
Blabber.LOGGER.warn("(Blabber) {}#{} only has conditional paths to the end of the dialogue", id, bad.getKey());
} else {
Blabber.LOGGER.error("(Blabber) {}#{} does not have any path to the end of the dialogue", id, bad.getKey());
return false;
}
}

return true;
}

@Override
public CompletableFuture<Void> apply(Map<Identifier, DialogueTemplate> data, ResourceManager manager, Profiler profiler, Executor executor) {
return CompletableFuture.runAsync(() -> DialogueRegistry.setEntries(data), executor);
Expand All @@ -173,9 +115,4 @@ public void endDataPackReload(MinecraftServer server, LifecycledResourceManager

private DialogueLoader() {}

private enum Reachability {
NONE,
CONDITIONAL,
PROVEN,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public void readFromNbt(NbtCompound tag) {
if (tag.contains("current_dialogue_id", NbtElement.STRING_TYPE)) {
Identifier dialogueId = Identifier.tryParse(tag.getString("current_dialogue_id"));
if (dialogueId != null) {
Optional<DialogueTemplate> dialogueTemplate = BlabberRegistrar.getDialogueTemplate(this.player.getWorld(), dialogueId);
Optional<DialogueTemplate> dialogueTemplate = DialogueRegistry.getOrEmpty(dialogueId);
if (dialogueTemplate.isPresent()) {
UUID interlocutorUuid = tag.containsUuid("interlocutor") ? tag.getUuid("interlocutor") : null;
String selectedState = tag.contains("current_dialogue_state", NbtElement.STRING_TYPE) ? tag.getString("current_dialogue_state") : null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Blabber
* Copyright (C) 2022-2023 Ladysnake
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser 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
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; If not, see <https://www.gnu.org/licenses>.
*/
package org.ladysnake.blabber.impl.common.validation;

public class DialogueLoadingException extends RuntimeException {
public DialogueLoadingException(String message) {
super(message);
}

public DialogueLoadingException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Blabber
* Copyright (C) 2022-2023 Ladysnake
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser 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
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; If not, see <https://www.gnu.org/licenses>.
*/
package org.ladysnake.blabber.impl.common.validation;

import org.ladysnake.blabber.impl.common.model.ChoiceResult;
import org.ladysnake.blabber.impl.common.model.DialogueChoice;
import org.ladysnake.blabber.impl.common.model.DialogueState;
import org.ladysnake.blabber.impl.common.model.DialogueTemplate;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

public final class DialogueValidator {
public static ValidationResult validateStructure(DialogueTemplate dialogue) {
Map<String, Map<String, Reachability>> parents = new HashMap<>();
Deque<String> waitList = new ArrayDeque<>();
Map<String, Reachability> unvalidated = new HashMap<>();
List<ValidationResult.Warning> warnings = new ArrayList<>();

for (Map.Entry<String, DialogueState> state : dialogue.states().entrySet()) {
if (state.getValue().type().equals(ChoiceResult.END_DIALOGUE)) {
waitList.add(state.getKey());
} else if (dialogue.states().get(state.getKey()).choices().isEmpty()) {
return new ValidationResult.Error.NoChoice(state.getKey());
} else {
unvalidated.put(state.getKey(), Reachability.NONE);

for (DialogueChoice choice : state.getValue().choices()) {
parents.computeIfAbsent(choice.next(), n -> new HashMap<>()).put(
state.getKey(),
choice.condition().isPresent() ? Reachability.CONDITIONAL : Reachability.PROVEN
);
}
}
}

while (!waitList.isEmpty()) {
String state = waitList.pop();
Map<String, Reachability> stateParents = parents.get(state);

if (stateParents != null) {
for (var parent : stateParents.entrySet()) {
Reachability reachability = unvalidated.get(parent.getKey());

if (reachability != null) { // leave it alone if it was already validated through another branch
if (reachability == Reachability.NONE) { // only check once
waitList.add(parent.getKey());
}

switch (parent.getValue()) {
case PROVEN -> unvalidated.remove(parent.getKey());
case CONDITIONAL -> unvalidated.put(parent.getKey(), Reachability.CONDITIONAL);
default -> throw new IllegalStateException("Unexpected parent-child reachability " + parent.getValue());
}
}
}
} // else, state is unreachable - we log that in the next part
}

for (var bad : unvalidated.entrySet()) {
if (!Objects.equals(bad.getKey(), dialogue.start()) && !parents.containsKey(bad.getKey())) {
// Unreachable states do not cause infinite loops, but we still want to be aware of them
warnings.add(new ValidationResult.Warning.Unreachable(bad.getKey()));
} else if (bad.getValue() == Reachability.CONDITIONAL) {
warnings.add(new ValidationResult.Warning.ConditionalSoftLock(bad.getKey()));
} else {
return new ValidationResult.Error.SoftLock(bad.getKey());
}
}

return warnings.isEmpty() ? ValidationResult.success() : new ValidationResult.Warnings(warnings);
}

private enum Reachability {
NONE,
CONDITIONAL,
PROVEN,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Blabber
* Copyright (C) 2022-2023 Ladysnake
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser 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
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; If not, see <https://www.gnu.org/licenses>.
*/
package org.ladysnake.blabber.impl.common.validation;

import java.util.List;
import java.util.stream.Collectors;

public sealed interface ValidationResult {
static Success success() {
return Success.INSTANCE;
}

final class Success implements ValidationResult {
public static final Success INSTANCE = new Success();
}

record Warnings(List<Warning> warnings) implements ValidationResult {
public String message() {
return warnings().stream().map(Warning::message).collect(Collectors.joining());
}
}

sealed interface Warning {
String state();

String message();

record Unreachable(String state) implements Warning {
@Override
public String message() {
return state() + " is unreachable";
}
}

record ConditionalSoftLock(String state) implements Warning {
@Override
public String message() {
return state() + " only has conditional paths to the end of the dialogue";
}
}
}

sealed interface Error extends ValidationResult {
String state();

String message();

record NoChoice(String state) implements Error {
@Override
public String message() {
return state() + " has no available choices but is not an end state";
}
}

record SoftLock(String state) implements Error {
@Override
public String message() {
return state() + " does not have any path to the end of the dialogue";
}
}
}
}

0 comments on commit 9b8dcbf

Please sign in to comment.