feat(loader): quests.yaml loading + startQuest effect mapping

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 22:29:52 +02:00
parent 513c7cb8d9
commit a3600eadd7
6 changed files with 123 additions and 3 deletions

View File

@@ -0,0 +1,36 @@
package thb.jeanluc.adventure.loader;
import thb.jeanluc.adventure.loader.dto.ConditionDto;
import thb.jeanluc.adventure.loader.dto.EffectDto;
import thb.jeanluc.adventure.loader.dto.QuestDto;
import thb.jeanluc.adventure.loader.dto.QuestStageDto;
import thb.jeanluc.adventure.model.Quest;
import thb.jeanluc.adventure.model.QuestStage;
import java.util.ArrayList;
import java.util.List;
/** Builds {@link Quest} objects from {@link QuestDto}s (no reference resolution needed). */
public final class QuestFactory {
private QuestFactory() {
}
public static Quest fromDto(QuestDto dto) {
List<QuestStage> stages = new ArrayList<>();
if (dto.stages() != null) {
for (QuestStageDto s : dto.stages()) {
stages.add(new QuestStage(
s.objective(),
ConditionDto.toModelList(s.completion()),
EffectDto.toModelList(s.onComplete())));
}
}
return new Quest(
dto.id(),
dto.title(),
Boolean.TRUE.equals(dto.autoStart()),
stages,
EffectDto.toModelList(dto.onComplete()));
}
}

View File

@@ -8,9 +8,11 @@ import lombok.extern.slf4j.Slf4j;
import thb.jeanluc.adventure.loader.dto.GameDto;
import thb.jeanluc.adventure.loader.dto.ItemDto;
import thb.jeanluc.adventure.loader.dto.NpcDto;
import thb.jeanluc.adventure.loader.dto.QuestDto;
import thb.jeanluc.adventure.loader.dto.RoomDto;
import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Quest;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.World;
import thb.jeanluc.adventure.model.item.Item;
@@ -68,11 +70,13 @@ public class WorldLoader {
List<ItemDto> itemDtos = readList(basePath + "/items.yaml", ItemDto.class);
List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class);
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList());
requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList());
requireUniqueIds("quest", questDtos.stream().map(QuestDto::id).toList());
Map<String, Item> items = new HashMap<>();
for (ItemDto dto : itemDtos) {
@@ -86,6 +90,10 @@ public class WorldLoader {
for (RoomDto dto : roomDtos) {
rooms.put(dto.id(), RoomFactory.shellFromDto(dto));
}
Map<String, Quest> quests = new HashMap<>();
for (QuestDto dto : questDtos) {
quests.put(dto.id(), QuestFactory.fromDto(dto));
}
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
resolver.resolveRooms(roomDtos);
@@ -98,7 +106,7 @@ public class WorldLoader {
Player player = new Player(start, gold);
World world = new World(rooms, items, npcs,
gameDto.title(), gameDto.welcomeMessage());
gameDto.title(), gameDto.welcomeMessage(), quests);
log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
gameDto.title(), rooms.size(), items.size(), npcs.size());
return new LoadResult(world, player);
@@ -114,6 +122,19 @@ public class WorldLoader {
}
}
private <T> List<T> readListOptional(String resource, Class<T> elementType) {
try (InputStream in = getClass().getResourceAsStream(resource)) {
if (in == null) {
return List.of();
}
CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType);
List<T> result = yaml.readValue(in, type);
return result == null ? List.of() : result;
} catch (IOException e) {
throw new WorldLoadException("Failed to parse " + resource, e);
}
}
private <T> T readSingle(String resource, Class<T> type) {
try (InputStream in = openResource(resource)) {
return yaml.readValue(in, type);

View File

@@ -7,7 +7,13 @@ import java.util.ArrayList;
import java.util.List;
/** YAML effect: exactly one of the fields is set. */
public record EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say) {
public record EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem,
String say, String startQuest) {
/** Backward-compatible constructor without startQuest. */
public EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say) {
this(setFlag, clearFlag, giveItem, removeItem, say, null);
}
public Effect toModel() {
if (setFlag != null) {
@@ -25,7 +31,11 @@ public record EffectDto(String setFlag, String clearFlag, String giveItem, Strin
if (say != null) {
return new Effect(Effect.Type.SAY, say);
}
throw new WorldLoadException("Effect must set one of setFlag/clearFlag/giveItem/removeItem/say");
if (startQuest != null) {
return new Effect(Effect.Type.START_QUEST, startQuest);
}
throw new WorldLoadException(
"Effect must set one of setFlag/clearFlag/giveItem/removeItem/say/startQuest");
}
public static List<Effect> toModelList(List<EffectDto> dtos) {

View File

@@ -0,0 +1,8 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a quest. */
public record QuestDto(String id, String title, Boolean autoStart,
List<QuestStageDto> stages, List<EffectDto> onComplete) {
}

View File

@@ -0,0 +1,7 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a quest stage. */
public record QuestStageDto(String objective, List<ConditionDto> completion, List<EffectDto> onComplete) {
}

View File

@@ -0,0 +1,38 @@
package thb.jeanluc.adventure.loader;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.loader.dto.ConditionDto;
import thb.jeanluc.adventure.loader.dto.EffectDto;
import thb.jeanluc.adventure.loader.dto.QuestDto;
import thb.jeanluc.adventure.loader.dto.QuestStageDto;
import thb.jeanluc.adventure.model.Effect;
import thb.jeanluc.adventure.model.Quest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class QuestLoadingTest {
@Test
void factoryMapsStagesConditionsAndEffects() {
QuestDto dto = new QuestDto("q", "Title", true,
List.of(new QuestStageDto("obj",
List.of(new ConditionDto("power_on", null, null)),
List.of(new EffectDto("done", null, null, null, null)))),
List.of());
Quest q = QuestFactory.fromDto(dto);
assertThat(q.autoStart()).isTrue();
assertThat(q.stages()).hasSize(1);
assertThat(q.stages().getFirst().objective()).isEqualTo("obj");
assertThat(q.stages().getFirst().completion()).hasSize(1);
assertThat(q.stages().getFirst().onComplete().getFirst().type()).isEqualTo(Effect.Type.SET_FLAG);
}
@Test
void startQuestEffectMaps() {
assertThat(new EffectDto(null, null, null, null, null, "q").toModel().type())
.isEqualTo(Effect.Type.START_QUEST);
}
}