From 3bb22e16c435e66f20cb2ef1fa0e291dfb6d3b62 Mon Sep 17 00:00:00 2001 From: kimgoetzke <120580433+kimgoetzke@users.noreply.github.com> Date: Sun, 29 Oct 2023 21:00:55 +0000 Subject: [PATCH] Create MVP with CLI game loop --- README.md | 110 ++++++++ build.gradle | 14 +- docs/HOW_TO_YAML_EVENTS.md | 121 +++++++++ settings.gradle | 2 +- .../king_of_castrop_rauxel/Application.java | 18 +- .../king_of_castrop_rauxel/action/Action.java | 28 +- .../action/ActionHandler.java | 155 +++++++++++ .../action/BuyAction.java | 40 +++ .../action/CombatAction.java | 28 ++ .../action/DialogueAction.java | 47 ++++ .../action/EventAction.java | 47 ++++ .../action/ExitAction.java | 32 +++ .../action/LocationAction.java | 67 +++++ .../action/PlayerAction.java | 13 - .../action/PoiAction.java | 34 +++ .../action/StateAction.java | 25 ++ .../action/debug/DebugAction.java | 33 +++ .../action/debug/DebugActionFactory.java | 152 +++++++++++ .../action/debug/Debuggable.java | 6 + .../characters/BasicEnemy.java | 61 +++++ .../characters/Combatant.java | 58 +++++ .../characters/Inhabitant.java | 98 +++++++ .../characters/Npc.java | 30 +++ .../characters/Player.java | 130 ++++++++++ .../characters/Visitor.java | 4 + .../cli/CliComponent.java | 216 ++++++++++++++++ .../king_of_castrop_rauxel/cli/CliGame.java | 63 +++++ .../king_of_castrop_rauxel/cli/NewGame.java | 31 --- .../cli/ProgressBar.java | 58 +++++ .../cli/combat/Encounter.java | 216 ++++++++++++++++ .../cli/loop/AbstractLoop.java | 151 +++++++++++ .../cli/loop/ChoosePoiLoop.java | 40 +++ .../cli/loop/CombatLoop.java | 32 +++ .../cli/loop/DebugLoop.java | 40 +++ .../cli/loop/DialogueLoop.java | 71 ++++++ .../cli/loop/PoiLoop.java | 40 +++ .../configuration/AppConfiguration.java | 76 ++++++ .../configuration/AppConstants.java | 108 ++++++++ .../configuration/AppProperties.java | 46 ++++ .../configuration/EnvironmentResolver.java | 23 ++ .../encounter/Damage.java | 32 +++ .../encounter/DungeonDetails.java | 59 +++++ .../encounter/EncounterBuilder.java | 154 +++++++++++ .../encounter/EncounterSequence.java | 54 ++++ .../encounter/EnemyDetails.java | 24 ++ .../event/Dialogue.java | 69 +++++ .../event/DialogueEvent.java | 34 +++ .../king_of_castrop_rauxel/event/Event.java | 201 +++++++++++++++ .../event/EventDetails.java | 38 +++ .../event/Interaction.java | 36 +++ .../king_of_castrop_rauxel/event/Loot.java | 73 ++++++ .../event/Participant.java | 12 + .../event/ReachEvent.java | 33 +++ .../king_of_castrop_rauxel/event/Reward.java | 59 +++++ .../king_of_castrop_rauxel/event/Role.java | 6 + .../game/GameHandler.java | 63 +++++ .../king_of_castrop_rauxel/graphs/Edge.java | 5 + .../king_of_castrop_rauxel/graphs/Graph.java | 93 +++++++ .../king_of_castrop_rauxel/graphs/Vertex.java | 75 ++++++ .../king_of_castrop_rauxel/items/Buyable.java | 14 + .../items/Consumable.java | 55 ++++ .../items/ConsumableService.java | 26 ++ .../items/ConsumablesRepository.java | 12 + .../location/AbstractAmenity.java | 64 ++++- .../location/AbstractLocation.java | 63 ++++- .../location/AbstractSettlement.java | 106 ++++++-- .../location/Amenity.java | 72 ++++-- .../location/Dungeon.java | 101 ++++++++ .../location/Location.java | 40 ++- .../location/LocationBuilder.java | 152 +++++++++++ .../location/LocationHistory.java | 14 - .../location/Neighbour.java | 4 - .../location/PointOfInterest.java | 36 +++ .../location/Settlement.java | 145 +++++++---- .../king_of_castrop_rauxel/location/Shop.java | 73 ++++++ .../king_of_castrop_rauxel/location/Size.java | 20 ++ .../{utils => location}/Visitable.java | 4 +- .../king_of_castrop_rauxel/player/Player.java | 34 --- .../settings/LocationComponent.java | 102 -------- .../utils/BasicEventGenerator.java | 180 +++++++++++++ .../utils/BasicNameGenerator.java | 190 ++++++++++++++ .../utils/BasicStringGenerator.java | 129 ---------- .../utils/BasicTerrainGenerator.java | 45 ++++ .../utils/DataServices.java | 5 + .../utils/EventDto.java | 18 ++ .../utils/EventGenerator.java | 13 + .../utils/FolderReader.java | 119 +++++++++ .../utils/Generatable.java | 7 - .../utils/Generator.java | 7 + .../utils/Generators.java | 18 ++ .../utils/NameGenerator.java | 25 ++ .../utils/PlaceholderProcessor.java | 98 +++++++ .../utils/TerrainGenerator.java | 11 + .../utils/TxtReader.java | 39 +++ .../king_of_castrop_rauxel/utils/Visitor.java | 4 - .../utils/YamlReader.java | 46 ++++ .../king_of_castrop_rauxel/world/Bounds.java | 22 ++ .../king_of_castrop_rauxel/world/Chunk.java | 138 ++++++++++ .../world/ChunkBuilder.java | 67 +++++ .../world/Coordinates.java | 169 ++++++++++++ .../world/Generatable.java | 12 + .../world/IdBuilder.java | 64 +++++ .../world/Randomisable.java | 8 + .../king_of_castrop_rauxel/world/Range.java | 48 ++++ .../world/SeedBuilder.java | 34 +++ .../world/Unloadable.java | 10 + .../king_of_castrop_rauxel/world/World.java | 205 +++++++++++++++ .../world/WorldHandler.java | 240 ++++++++++++++++++ src/main/resources/application.properties | 39 ++- src/main/resources/banner.txt | 18 ++ .../V1__create_items_consumables.sql | 24 ++ .../multi-step/the-obvious-question.yml | 69 +++++ .../events/reach/a-close-friends-parcel.yml | 116 +++++++++ .../events/reach/urgent-delivery.yml | 117 +++++++++ .../events/single-step/npc-dismissive.txt | 27 ++ src/main/resources/names/Amenity-ENTRANCE.txt | 8 - src/main/resources/names/Amenity-SHOP.txt | 8 - src/main/resources/names/Amenity-SQUARE.txt | 3 - src/main/resources/names/Amenity.txt | 22 -- .../resources/names/amenity-entrance-s.txt | 5 + src/main/resources/names/amenity-entrance.txt | 8 + .../resources/names/amenity-main_square-s.txt | 4 + .../resources/names/amenity-main_square.txt | 3 + ...OCATION.txt => amenity-quest_location.txt} | 5 +- .../resources/names/amenity-shop-alchemy.txt | 26 ++ .../resources/names/amenity-shop-general.txt | 28 ++ src/main/resources/names/amenity-square.txt | 3 + src/main/resources/names/amenity.txt | 86 +++++++ .../names/basicenemy--type-prefix.txt | 79 ++++++ src/main/resources/names/dungeon-goblin.txt | 6 + src/main/resources/names/dungeon.txt | 11 + .../resources/names/inhabitant--fallback.txt | 4 + .../resources/names/inhabitant-first_name.txt | 50 ++++ .../resources/names/inhabitant-last_name.txt | 48 ++++ ...ettlement--end.txt => settlement--end.txt} | 0 ...ent--middle.txt => settlement--middle.txt} | 0 ...ement--start.txt => settlement--start.txt} | 0 .../ApplicationTests.java | 11 +- .../utils/YamlReaderTest.java | 101 ++++++++ .../world/AutoUnloadingTest.java | 103 ++++++++ .../world/BaseWorldTest.java | 156 ++++++++++++ .../world/BasicTerrainGeneratorTest.java | 53 ++++ .../world/CoordinatesTest.java | 162 ++++++++++++ .../world/WorldHandlerTest.java | 222 ++++++++++++++++ src/test/resources/application.properties | 5 + src/test/resources/yaml-reader-test-file.yml | 117 +++++++++ 146 files changed, 8028 insertions(+), 511 deletions(-) create mode 100644 README.md create mode 100644 docs/HOW_TO_YAML_EVENTS.md create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/ActionHandler.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/BuyAction.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/CombatAction.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/DialogueAction.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/EventAction.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/ExitAction.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/LocationAction.java delete mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/PlayerAction.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/PoiAction.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/StateAction.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/debug/DebugAction.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/debug/DebugActionFactory.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/action/debug/Debuggable.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/characters/BasicEnemy.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Combatant.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Inhabitant.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Npc.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Player.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Visitor.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/cli/CliComponent.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/cli/CliGame.java delete mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/cli/NewGame.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/cli/ProgressBar.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/cli/combat/Encounter.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/AbstractLoop.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/ChoosePoiLoop.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/CombatLoop.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/DebugLoop.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/DialogueLoop.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/PoiLoop.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/AppConfiguration.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/AppConstants.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/AppProperties.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/EnvironmentResolver.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/Damage.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/DungeonDetails.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/EncounterBuilder.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/EncounterSequence.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/EnemyDetails.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/event/Dialogue.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/event/DialogueEvent.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/event/Event.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/event/EventDetails.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/event/Interaction.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/event/Loot.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/event/Participant.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/event/ReachEvent.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/event/Reward.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/event/Role.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/game/GameHandler.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/graphs/Edge.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/graphs/Graph.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/graphs/Vertex.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/items/Buyable.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/items/Consumable.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/items/ConsumableService.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/items/ConsumablesRepository.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/location/Dungeon.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/location/LocationBuilder.java delete mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/location/LocationHistory.java delete mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/location/Neighbour.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/location/PointOfInterest.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/location/Shop.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/location/Size.java rename src/main/java/com/hindsight/king_of_castrop_rauxel/{utils => location}/Visitable.java (53%) delete mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/player/Player.java delete mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/settings/LocationComponent.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicEventGenerator.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicNameGenerator.java delete mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicStringGenerator.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicTerrainGenerator.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/DataServices.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/EventDto.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/EventGenerator.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/FolderReader.java delete mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Generatable.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Generator.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Generators.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/NameGenerator.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/PlaceholderProcessor.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/TerrainGenerator.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/TxtReader.java delete mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Visitor.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/utils/YamlReader.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/world/Bounds.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/world/Chunk.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/world/ChunkBuilder.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/world/Coordinates.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/world/Generatable.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/world/IdBuilder.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/world/Randomisable.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/world/Range.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/world/SeedBuilder.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/world/Unloadable.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/world/World.java create mode 100644 src/main/java/com/hindsight/king_of_castrop_rauxel/world/WorldHandler.java create mode 100644 src/main/resources/banner.txt create mode 100644 src/main/resources/db/migration/V1__create_items_consumables.sql create mode 100644 src/main/resources/events/multi-step/the-obvious-question.yml create mode 100644 src/main/resources/events/reach/a-close-friends-parcel.yml create mode 100644 src/main/resources/events/reach/urgent-delivery.yml create mode 100644 src/main/resources/events/single-step/npc-dismissive.txt delete mode 100644 src/main/resources/names/Amenity-ENTRANCE.txt delete mode 100644 src/main/resources/names/Amenity-SHOP.txt delete mode 100644 src/main/resources/names/Amenity-SQUARE.txt delete mode 100644 src/main/resources/names/Amenity.txt create mode 100644 src/main/resources/names/amenity-entrance-s.txt create mode 100644 src/main/resources/names/amenity-entrance.txt create mode 100644 src/main/resources/names/amenity-main_square-s.txt create mode 100644 src/main/resources/names/amenity-main_square.txt rename src/main/resources/names/{Amenity-QUEST_LOCATION.txt => amenity-quest_location.txt} (87%) create mode 100644 src/main/resources/names/amenity-shop-alchemy.txt create mode 100644 src/main/resources/names/amenity-shop-general.txt create mode 100644 src/main/resources/names/amenity-square.txt create mode 100644 src/main/resources/names/amenity.txt create mode 100644 src/main/resources/names/basicenemy--type-prefix.txt create mode 100644 src/main/resources/names/dungeon-goblin.txt create mode 100644 src/main/resources/names/dungeon.txt create mode 100644 src/main/resources/names/inhabitant--fallback.txt create mode 100644 src/main/resources/names/inhabitant-first_name.txt create mode 100644 src/main/resources/names/inhabitant-last_name.txt rename src/main/resources/names/{Settlement--end.txt => settlement--end.txt} (100%) rename src/main/resources/names/{Settlement--middle.txt => settlement--middle.txt} (100%) rename src/main/resources/names/{Settlement--start.txt => settlement--start.txt} (100%) create mode 100644 src/test/java/com/hindsight/king_of_castrop_rauxel/utils/YamlReaderTest.java create mode 100644 src/test/java/com/hindsight/king_of_castrop_rauxel/world/AutoUnloadingTest.java create mode 100644 src/test/java/com/hindsight/king_of_castrop_rauxel/world/BaseWorldTest.java create mode 100644 src/test/java/com/hindsight/king_of_castrop_rauxel/world/BasicTerrainGeneratorTest.java create mode 100644 src/test/java/com/hindsight/king_of_castrop_rauxel/world/CoordinatesTest.java create mode 100644 src/test/java/com/hindsight/king_of_castrop_rauxel/world/WorldHandlerTest.java create mode 100644 src/test/resources/application.properties create mode 100644 src/test/resources/yaml-reader-test-file.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4c80b6 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# Procedural Generation Project 1 + +This project was my first attempt to procedurally generate, well, anything really. I didn't know anything about this +topic but ended up with an old-school text-based adventure game world where the player can travel between locations, +interact with non-player characters, and engage in combat. + +### Features + +#### Procedural generation + +- All objects below are generated procedurally, using a seeded `Random` object and handled by a `WorldHandler` instance +- The core object is `World` which holds `Chunk[][]`, with the player starting in the centre chunk +- Each `Chunk` holds an `int[][]`: + - Based on `int density`, a number of `Location`s are placed in the `Chunk` + - `Location`s are connected using various, configurable strategies which result in the `WorldHandler` + s `Graph` +- A `Location` (interface) contains reference to neighbouring locations, points of interest inside it, and its location + within the chunk and world + - The only `Location` available at this stage is a `Settlement` + - For generation, the most important feature of a `Location` is its `Size` which determines the `PointOfInterest` + count and, for a `Settlement`, also the `Inhabitant` count + - Each `Location` holds a `List` which the player can visit +- The key object a player interacts with/within is a `PointOfInterest` (**POI**): + - A POIs features are determined by its `Type` + - Currently implemented are the following `Type`s: `ENTRANCE`, `MAIN_SQUARE`, `SHOP`, `QUEST_LOCATION` and `DUNGEON` + - At a POI, the player can engage in dialogues with non-player characters (**NPC**) other events (e.g. "delivery" + or "kill" quests), engage in combat, or take other actions +- Each object of each layer (i.e. `World`, `Chunk`, `Location` and `PointOfInterest`) can be located using `Coordinate` +- The web of connections and the distance between each (both of which stored in the `WorldHandler`s `Graph`) + play an important role e.g. where a player can travel to and how long it takes + +#### Player loop + +- When playing the game via a CLI, the `Player`'s `State` determines the player loop that is being executed +- Each state (e.g. `PoiLoop` or `CombatLoop`) inherits from `AbstractLoop` +- A loop follows a simple sequence such as this example from the `DialogueLoop`: + +```java +public class DialogueLoop extends AbstractLoop { + + @Override + public void execute(List actions) { + printInteraction(); // Shows the current `Interaction` from the NPCs `Dialogue` + prepareActions(actions); // Reads the available `Action`s for current `Dialogue` from `Event` + promptPlayer(actions); // Shows the above as a `List` & executes the selection `Action` + postProcess(); // Handles side effects of the outcome e.g. updating the `Event` + } +} + +``` + +- Almost every loop will `prepareActions()` and `promptPlayer()` +- The `Action` interface is the way through which the `Player` affects the world - examples: + - Move to a different `Location` using `LocationAction` + - Move to a different `PointOfInterest` using `PoiAction` + - Start with an `EventAction` + - Changing the `Player`s `State` using `StateAction` + +#### Other technical features + +- Folder scanning (`FolderReader`) to read available event file names by category (e.g. events) which works inside a JAR + and when running via an IDE +- Processing of Yaml files (`YamlReader`) which allows customising event `Participant`s based on their role ( + e.g. `eventGiver` or `eventTarget`), `List` for each based on event status, `List`, etc. +- Processing of Txt files (`TxtReader`) which is used to generate `Location`, `PointOfInterest` and `Npc` names +- Processing of `String` placeholders in Yaml or Txt files (`PlaceholderProcessor`) e.g. `&L` for location name + or `&TOF` for the target owner's first name through which events are tailored to the current places and characters + involved + +### Technologies used + +- Core: Java 19, Spring Boot 3 + Lombok, Gradle +- `guava` for String manipulation +- `snakeyaml` for Yaml processing +- `google-java-format` for formatting + +### Other notes + +#### More documentation + +- [How to create event YAML files](docs/HOW_TO_YAML_EVENTS.md) + +#### Random ideas for next steps + +- **(User interface)**: Implement Restful API and a web interface as alternative for CLI-based player loop +- **(Procedural generation)**: Implement biomes which: + - Determine difficulty of events, attitude towards player, etc. based on environmental factors + - Determine object characteristics such as names and types of events +- **(Game design)**: Come up with a key objective for the player and an actual (i.e. fun) game loop +- **(Game design)**: Create more `Location` types such as `Castle` +- **(Game design)**: Create more amenities (`PointOfInterest`) with specific functions i.e. shops +- **(Game design)**: Implement player equipment, inventory, and item drops +- **(Game design)**: Implement a trade/currency system and the ability to buy/sell equipment + +### Notes + +#### Formatter + +This project uses `google-java-format`. See https://github.com/google/google-java-format for more details on how to set +it up and use it. + +#### How to run JAR + +```shell +cd build\libs +java -jar -D"spring.profiles.active"=cli-prod procedural_generation_1-0.1.jar +``` + +Alternatively, in IntelliJ create new run configuration with path to JAR and with VM +options `-Dspring.profiles.active=cli-prod`. diff --git a/build.gradle b/build.gradle index d13c3c0..7f81170 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'com.hindsight' -version = '0.0.1-SNAPSHOT' +version = '0.1' java { sourceCompatibility = '19' @@ -25,8 +25,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.flywaydb:flyway-core' + implementation 'com.google.guava:guava:32.1.2-jre' + implementation 'org.yaml:snakeyaml:2.2' + implementation 'de.codeshelf.consoleui:consoleui:0.0.13' compileOnly 'org.projectlombok:lombok' - developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' @@ -35,3 +37,11 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +jar { + manifest { + attributes( + 'Main-Class': 'com.hindsight.king_of_castrop_rauxel.Application' + ) + } +} \ No newline at end of file diff --git a/docs/HOW_TO_YAML_EVENTS.md b/docs/HOW_TO_YAML_EVENTS.md new file mode 100644 index 0000000..a1738ce --- /dev/null +++ b/docs/HOW_TO_YAML_EVENTS.md @@ -0,0 +1,121 @@ +# How to create event YAML files + +This document describes how to create event YAML files. The project uses YAML files to define the content for events. +YAML files stored in `/src/main/resources/events` are read by `YamlReader` and loaded at runtime by the application. + +## Structure + +YAML files are read to `EventDto` objects. They require the following elements: + +1. `eventDetails` -> `EventDetails` +2. `participantData` -> `Map>` + +### Event details + +The `eventDetails` element must contain the `eventType` which is parsed to `EventType` enum (see `Event` class). +All other fields of the class are optional. An `id` is generated automatically and should not be provided. + +```yaml +eventDetails: + eventType: REACH # Event.Type enum + aboutGiver: a delivery # Optional, type String + aboutTarget: the parcel of &O # Optional, type String + rewards: # Optional, type List + - type: GOLD # Reward.Type enum + minValue: 2 + maxValue: 15 +``` + +### Participant data + +The `participantData` element must contain a `Map` of `Role` to `List`. The `Role` is parsed to `Role` enum ( +see `Role` class). + +Example: + +```yaml +participantData: + EVENT_GIVER: # Role enum + - !dialogue # Indicate mapping to Dialogue class + state: AVAILABLE # Event.State enum + interactions: # List class + - text: Hello! # An interaction; type String + i: 0 # Optional, NOT parsed; type int; interaction number to make it easier to link actions to interactions + - text: How are you? + i: 1 + actions: + - !action # Indicate mapping to Action class + name: (Accept) Alright, I'll do it # Action name, type String + eventState: NONE # Event.State enum + nextInteraction: 1 # The next interaction to display after selection; + # this example will lead to an infinite loop +``` + +### Actions + +- The dialogue of an event can only be exited through an action +- An action can change the state of the event (`eventState`) e.g. from `AVAILABLE` to `COMPLETED` +- An action can also change the state of the player (`playerState`) e.g. from `IN_DIALOGUE` to `AT_POI`, marking the end + of the dialogue +- Both `eventState` and `playerState` can be changed in a single action +- If an action should only advance the dialogue to a different interaction, you must set `eventState` to `NONE` and set + `nextInteraction` to the index of the next interaction + +### Examples + +#### How to create a simple dialogue + +```yaml +eventDetails: + eventType: DIALOGUE + # Add Event.Type DIALOGUE enum + # ...and skip all other eventDetails +participantDetails: + EVENT_GIVER: + - ... +``` + +#### How to branch a dialogue + +```yaml + - !dialogue + state: AVAILABLE + interactions: + - text: Can you do this for me? + i: 0 + actions: # Add an action + - !action # Indicate mapping to Action class + name: (Accept) Alright, I'll do it + eventState: NONE # Set eventState to NONE + nextInteraction: 1 # Set nextInteraction to anything you want + - !action + name: (Decline) I'm sorry, I can't + eventState: NONE + nextInteraction: 2 + - text: Great, thank you! + i: 1 + - text: That's a shame! + i: 2 +``` + +Note that in the example above, "That's a shame!" would be displayed right after "Great, thank you!". When doing +branching like this, make sure to use additional actions to change the event state or end the dialogue to avoid this. + +#### How to end a dialogue + +```yaml + - !dialogue + state: DECLINED + interactions: + - text: Alright, see you around. + actions: # Add an action + - !action # Indicate mapping to Action class + name: Goodbye! # Give it any text/name + playerState: AT_POI # Set playerState to AT_POI +``` + +## Other notes + +Due to the scope and purpose of this project, there is currently no pre-processing of the YAML files. This means that +the YAML files are read when they are assigned to an NPC. This means that any errors in the YAML files will only be +detected at that point. \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 7950e53..3628ec7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'king_of_castrop_rauxel' +rootProject.name = 'procedural_generation_1' diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/Application.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/Application.java index 8c0557a..da4bc84 100644 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/Application.java +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/Application.java @@ -1,18 +1,24 @@ package com.hindsight.king_of_castrop_rauxel; -import com.hindsight.king_of_castrop_rauxel.cli.NewGame; +import com.hindsight.king_of_castrop_rauxel.cli.CliGame; +import com.hindsight.king_of_castrop_rauxel.configuration.AppProperties; import lombok.extern.slf4j.Slf4j; +import org.fusesource.jansi.AnsiConsole; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; @Slf4j @SpringBootApplication +@EnableConfigurationProperties(AppProperties.class) public class Application { - public static void main(String[] args) { - SpringApplication.run(Application.class, args); - var newGame = new NewGame(); - newGame.start(); + public static void main(String[] args) { + var context = SpringApplication.run(Application.class, args); + if (Boolean.TRUE.equals(AppProperties.getIsRunningAsJar())) { + AnsiConsole.systemInstall(); } - + var newGame = context.getBean(CliGame.class); + newGame.play(); + } } diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/Action.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/Action.java index b7a79c8..27693bb 100644 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/Action.java +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/Action.java @@ -1,6 +1,32 @@ package com.hindsight.king_of_castrop_rauxel.action; +import static com.hindsight.king_of_castrop_rauxel.characters.Player.*; +import static com.hindsight.king_of_castrop_rauxel.cli.CliComponent.*; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; + public interface Action { + + int getIndex(); + + void setIndex(int index); + String getName(); - int getNumber(); + + void setName(String name); + + State getNextState(); + + default void execute(Player player) { + nextState(player); + } + + default void nextState(Player player) { + player.setState(getNextState()); + } + + default String print() { + return "%s[%s%s%s]%s %s" + .formatted(FMT.WHITE, FMT.CYAN, getIndex(), FMT.WHITE, FMT.RESET, getName()); + } } diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/ActionHandler.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/ActionHandler.java new file mode 100644 index 0000000..2de6f1d --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/ActionHandler.java @@ -0,0 +1,155 @@ +package com.hindsight.king_of_castrop_rauxel.action; + +import static com.hindsight.king_of_castrop_rauxel.characters.Player.State.*; + +import com.hindsight.king_of_castrop_rauxel.action.debug.DebugActionFactory; +import com.hindsight.king_of_castrop_rauxel.action.debug.Debuggable; +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.cli.CliComponent; +import com.hindsight.king_of_castrop_rauxel.configuration.EnvironmentResolver; +import com.hindsight.king_of_castrop_rauxel.location.Dungeon; +import com.hindsight.king_of_castrop_rauxel.location.Location; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor(access = lombok.AccessLevel.PRIVATE, onConstructor = @__(@Autowired)) +public class ActionHandler { + + private final EnvironmentResolver environmentResolver; + private final DebugActionFactory debug; + + private void prepend(List actions) { + actions.clear(); + } + + private void append(List actions) { + if (environmentResolver.isDev()) { + actions.add(new StateAction(index(actions), "Show debug menu", DEBUGGING)); + } + if (environmentResolver.isCli()) { + actions.add(new ExitAction(index(actions), "Exit game")); + } + } + + public void getChoosePoiActions(Player player, List actions) { + prepend(actions); + var poi = player.getCurrentPoi(); + var stayHereAction = new PoiAction(index(actions), "Stay at " + poi.getName(), poi); + actions.add(stayHereAction); + addAllActionsFrom(player.getCurrentLocation().getAvailableActions(), actions, stayHereAction); + append(actions); + } + + public void getThisPoiActions(Player player, List actions) { + prepend(actions); + var poi = player.getCurrentPoi(); + var currentLocation = player.getCurrentLocation(); + addGoToPoiAction(actions, currentLocation); + if (poi == currentLocation.getDefaultPoi()) { + addLocationActions(actions, currentLocation, player); + } + addAllActionsFrom(poi.getAvailableActions(), actions); + append(actions); + } + + public void getDialogueActions(Player player, List actions) { + prepend(actions); + var eventActions = player.getCurrentEvent().getCurrentActions(); + addAllActionsFrom(eventActions, actions); + } + + public void getCombatActions(Player player, List actions) { + prepend(actions); + if (player.getCurrentPoi() instanceof Dungeon dungeon) { + var sequence = dungeon.getSequence(); + if (sequence.isInProgress()) { + actions.add(new CombatAction(index(actions), "Press on", sequence)); + actions.add(new StateAction(index(actions), "Retreat (for now)", AT_POI)); + } else { + actions.add(new StateAction(index(actions), "Return victoriously", AT_POI)); + } + } + } + + public void getDebugActions(Player player, List actions) { + prepend(actions); + var triggerZone = (Debuggable) () -> debug.logLocationsInsideTriggerZone(player); + var visitedLocs = (Debuggable) () -> debug.logVisitedLocations(player); + var visitedLocsAction = debug.create(index(actions), "Log visited locations", visitedLocs); + actions.add(new LocationAction(index(actions), "Resume game", player.getCurrentLocation())); + actions.add(debug.create(index(actions), "Log memory usage", debug::logMemoryStats)); + actions.add(debug.create(index(actions), "Log all locations", debug::logVertices)); + actions.add(debug.create(index(actions), "Log locations inside trigger zone", triggerZone)); + actions.add(visitedLocsAction); + actions.add(debug.create(index(actions), "Log graph connectivity", debug::printConnectivity)); + actions.add(debug.create(index(actions), "Log graph edges & distances", debug::logGraph)); + actions.add(debug.create(index(actions), "Log close chunks", debug::logWorld)); + actions.add(debug.create(index(actions), "Print visualised plane", debug::printPlane)); + append(actions); + } + + public void getNone(List actions) { + actions.clear(); + } + + private static int index(List actions) { + return actions.size() + 1; + } + + private static void addGoToPoiAction(List actions, Location currentLocation) { + var poisCount = currentLocation.getPointsOfInterest().size() - 1; + actions.add(new StateAction(index(actions), getGoToActionName(poisCount), CHOOSING_POI)); + } + + private static String getGoToActionName(int poisCount) { + var labelText = "%s point(s) of interest".formatted(poisCount); + var formattedLabel = CliComponent.label(labelText, CliComponent.FMT.BLUE); + return "Go to...%s".formatted(formattedLabel); + } + + private static void addLocationActions(List to, Location currentLocation, Player player) { + var from = currentLocation.getNeighbours().stream().toList(); + for (var n : from) { + var action = + new LocationAction(index(to), getLocationActionName(currentLocation, player, n), n); + to.add(action); + } + } + + private static String getLocationActionName(Location currentLocation, Player player, Location l) { + var hasBeenVisited = player.getVisitedLocations().stream().anyMatch(a -> a.equals(l)); + var visitedText = hasBeenVisited ? "" : ", unvisited"; + return "Travel to %s (%s km %s%s)%s" + .formatted( + l.getName(), + l.distanceTo(currentLocation), + l.getCardinalDirection(player.getCoordinates().getChunk()).getName().toLowerCase(), + visitedText, + CliComponent.label(CliComponent.Type.LOCATION)); + } + + private static void addAllActionsFrom(List from, List to) { + var adjustedActions = new ArrayList<>(from); + for (var action : adjustedActions) { + action.setIndex(index(to)); + to.add(action); + } + } + + private static void addAllActionsFrom(List from, List to, PoiAction except) { + var adjustedActions = new ArrayList<>(from); + for (var action : adjustedActions) { + if (action.getName().contains(except.getPoi().getName())) { + continue; + } + action.setIndex(index(to)); + to.add(action); + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/BuyAction.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/BuyAction.java new file mode 100644 index 0000000..81b6980 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/BuyAction.java @@ -0,0 +1,40 @@ +package com.hindsight.king_of_castrop_rauxel.action; + +import static com.hindsight.king_of_castrop_rauxel.characters.Player.*; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.cli.CliComponent; +import com.hindsight.king_of_castrop_rauxel.items.Buyable; +import lombok.Getter; +import lombok.Setter; + +/** This action exchange the player's gold for a Buyable such as a Consumable. */ +@Getter +public class BuyAction implements Action { + + @Setter private int index; + @Setter private String name; + private final Buyable item; + + public BuyAction(int index, Buyable item) { + this.index = index; + this.name = "Buy: " + CliComponent.buyable(item); + this.item = item; + } + + @Override + public void execute(Player player) { + var isBought = item.isBoughtBy(player); + var errorMessage = "Not enough gold to buy: '%s'.%n".formatted(item.getName()); + if (!isBought) { + System.out.printf(CliComponent.error(errorMessage)); + CliComponent.awaitEnterKeyPress(); + } + nextState(player); + } + + @Override + public State getNextState() { + return State.AT_POI; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/CombatAction.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/CombatAction.java new file mode 100644 index 0000000..d187b9d --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/CombatAction.java @@ -0,0 +1,28 @@ +package com.hindsight.king_of_castrop_rauxel.action; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.encounter.EncounterSequence; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +/** This action initiates an encounter by changing the state to IN_COMBAT and . */ +@Getter +@Builder +public class CombatAction implements Action { + + @Setter private int index; + @Setter private String name; + private EncounterSequence sequence; + + @Override + public void execute(Player player) { + nextState(player); + sequence.execute(player); + } + + @Override + public Player.State getNextState() { + return Player.State.IN_COMBAT; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/DialogueAction.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/DialogueAction.java new file mode 100644 index 0000000..41e848e --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/DialogueAction.java @@ -0,0 +1,47 @@ +package com.hindsight.king_of_castrop_rauxel.action; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.event.Event; +import lombok.*; + +/** This action is used in dialogue and allows branching logic. */ +@Getter +@Setter +@Builder +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class DialogueAction implements Action { + + @EqualsAndHashCode.Exclude private int index; + private String name; + private Event.State eventState; + private Player.State playerState; + private Integer nextInteraction; + + @Override + public void execute(Player player) { + if (playerState == null && eventState == null) { + throw new IllegalStateException("DialogueAction must have a playerState or eventState"); + } + if (playerState != null) { + player.setState(playerState); + } + if (eventState == Event.State.NONE && nextInteraction == null) { + throw new IllegalStateException("DialogueAction must have an eventState or nextInteraction"); + } + if (eventState != null) { + switch (eventState) { + // Subtract 1 because the dialogue will progress after this action is executed. + case NONE -> player.getCurrentEvent().setCurrentInteraction(nextInteraction - 1); + case COMPLETED -> player.getCurrentEvent().completeEvent(player); + default -> player.getCurrentEvent().progressEvent(eventState); + } + } + } + + @Override + public Player.State getNextState() { + return Player.State.IN_DIALOGUE; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/EventAction.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/EventAction.java new file mode 100644 index 0000000..ec24c3b --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/EventAction.java @@ -0,0 +1,47 @@ +package com.hindsight.king_of_castrop_rauxel.action; + +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.event.Event; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +/** + * This action initiates a dialogue by changing the state to IN_DIALOGUE and setting the current + * event on the player. + */ +@Getter +@Builder +public class EventAction implements Action { + + @Setter private int index; + @Setter private String name; + private Event event; + private Npc npc; + + public static EventAction from(EventAction action) { + return EventAction.builder() + .index(action.getIndex()) + .name(action.getName()) + .event(action.getEvent()) + .npc(action.getNpc()) + .build(); + } + + @Override + public void execute(Player player) { + if (!player.getEvents().contains(event) + || (event.isRepeatable() && event.getEventState() == Event.State.AVAILABLE)) { + player.addEvent(event); + } + event.setActive(npc); + player.setCurrentEvent(event); + nextState(player); + } + + @Override + public Player.State getNextState() { + return Player.State.IN_DIALOGUE; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/ExitAction.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/ExitAction.java new file mode 100644 index 0000000..acf98de --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/ExitAction.java @@ -0,0 +1,32 @@ +package com.hindsight.king_of_castrop_rauxel.action; + +import static com.hindsight.king_of_castrop_rauxel.characters.Player.*; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +/** + * The only purpose of this action is to exit the game when playing via the CLI. It will not be + * displayed in a web environment. + */ +@Getter +@Builder +public class ExitAction implements Action { + + @Setter private int index; + @Setter private String name; + private static final State NEXT_STATE = State.AT_POI; + + @Override + public void execute(Player player) { + nextState(player); + System.out.printf("Goodbye!%n%n"); + System.exit(0); + } + + public State getNextState() { + return NEXT_STATE; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/LocationAction.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/LocationAction.java new file mode 100644 index 0000000..7f19a90 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/LocationAction.java @@ -0,0 +1,67 @@ +package com.hindsight.king_of_castrop_rauxel.action; + +import static com.hindsight.king_of_castrop_rauxel.characters.Player.*; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.cli.ProgressBar; +import com.hindsight.king_of_castrop_rauxel.location.Location; +import com.hindsight.king_of_castrop_rauxel.location.PointOfInterest; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +/** + * This action is used to change the player's current location by setting the currentPoi to the + * defaultPoi of the new location. It also changes the player's state to AT_POI so that the player + * can see the actions of the default POI next. + */ +@Getter +@Builder +public class LocationAction implements Action { + + @Setter private int index; + @Setter private String name; + private Location location; + + @Override + public void execute(Player player) { + var thread = loadLocation(); + ProgressBar.displayProgress(player.getCurrentLocation(), location); + try { + thread.join(); + } catch (InterruptedException e) { + cancelAction(player, player.getCurrentPoi()); + } + executeAction(player, location.getDefaultPoi()); + } + + private Thread loadLocation() { + var thread = + new Thread( + () -> { + if (!location.isLoaded()) { + location.load(); + } + }); + thread.start(); + return thread; + } + + private void cancelAction(Player player, PointOfInterest poiLeaving) { + System.out.printf( + "%nSeems like you didn't know the way because you ended up where you started.%n"); + Thread.currentThread().interrupt(); + executeAction(player, poiLeaving); + } + + private void executeAction(Player player, PointOfInterest poiVisiting) { + System.out.println(); + nextState(player); + player.setCurrentPoi(poiVisiting); + } + + @Override + public State getNextState() { + return State.AT_POI; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/PlayerAction.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/PlayerAction.java deleted file mode 100644 index 5344c73..0000000 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/PlayerAction.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.hindsight.king_of_castrop_rauxel.action; - -import com.hindsight.king_of_castrop_rauxel.location.Location; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class PlayerAction implements Action { - private int number; - private String name; - private Location location; -} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/PoiAction.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/PoiAction.java new file mode 100644 index 0000000..2d698aa --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/PoiAction.java @@ -0,0 +1,34 @@ +package com.hindsight.king_of_castrop_rauxel.action; + +import static com.hindsight.king_of_castrop_rauxel.characters.Player.*; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.location.PointOfInterest; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +/** + * This action changes the player's current POI. It never changes the location. However, it does + * change the player's state to AT_POI, so that the player sees the POI's actions next. It is NOT + * used for any actions at the POI itself (unless changing POI is part of a quest). + */ +@Getter +@Builder +public class PoiAction implements Action { + + @Setter private int index; + @Setter private String name; + private PointOfInterest poi; + + @Override + public void execute(Player player) { + nextState(player); + player.setCurrentPoi(poi); + } + + @Override + public State getNextState() { + return State.AT_POI; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/StateAction.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/StateAction.java new file mode 100644 index 0000000..df33aa6 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/StateAction.java @@ -0,0 +1,25 @@ +package com.hindsight.king_of_castrop_rauxel.action; + +import static com.hindsight.king_of_castrop_rauxel.characters.Player.*; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +/** + * All actions change the player's state and something else. This action only changes the state of + * the player. For example, changing the state to DEBUG will show the debug menu. Changing the state + * to CHOOSE_POI will show the list of POIs in the current location. This action never changes the + * player's current location or POI. + */ +@Slf4j +@Getter +@Setter +@Builder +public class StateAction implements Action { + + private int index; + private String name; + private State nextState; +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/debug/DebugAction.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/debug/DebugAction.java new file mode 100644 index 0000000..12765c8 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/debug/DebugAction.java @@ -0,0 +1,33 @@ +package com.hindsight.king_of_castrop_rauxel.action.debug; + +import static com.hindsight.king_of_castrop_rauxel.characters.Player.*; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.graphs.Graph; +import com.hindsight.king_of_castrop_rauxel.location.AbstractLocation; +import com.hindsight.king_of_castrop_rauxel.world.World; +import lombok.*; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +@Builder +public class DebugAction implements Action { + + @Setter private int index; + @Setter private String name; + private Debuggable debuggable; + private Graph map; + private World world; + + @Override + public void execute(Player player) { + nextState(player); + debuggable.execute(); + } + + public State getNextState() { + return State.DEBUGGING; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/debug/DebugActionFactory.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/debug/DebugActionFactory.java new file mode 100644 index 0000000..f93837f --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/debug/DebugActionFactory.java @@ -0,0 +1,152 @@ +package com.hindsight.king_of_castrop_rauxel.action.debug; + +import static com.hindsight.king_of_castrop_rauxel.cli.CliComponent.*; +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.*; +import static com.hindsight.king_of_castrop_rauxel.world.ChunkBuilder.*; +import static com.hindsight.king_of_castrop_rauxel.world.Coordinates.*; +import static com.hindsight.king_of_castrop_rauxel.world.WorldHandler.*; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.graphs.Graph; +import com.hindsight.king_of_castrop_rauxel.graphs.Vertex; +import com.hindsight.king_of_castrop_rauxel.location.AbstractLocation; +import com.hindsight.king_of_castrop_rauxel.world.World; +import com.hindsight.king_of_castrop_rauxel.world.WorldHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor(onConstructor = @__(@Autowired)) +public class DebugActionFactory { + + private final Graph map; + private final World world; + private final WorldHandler worldHandler; + + public DebugAction create(int index, String name, Debuggable debuggable) { + return DebugAction.builder() + .index(index) + .name(name) + .debuggable(debuggable) + .map(map) + .world(world) + .build(); + } + + public void logGraph() { + map.log(); + } + + public void printConnectivity() { + worldHandler.logDisconnectedVertices(map); + } + + public void logVertices() { + log.info("All locations/vertices:"); + map.getVertices().stream() + .map(Vertex::getLocation) + .forEach(l -> log.info("- " + l.getFullSummary())); + } + + public void logMemoryStats() { + var rt = Runtime.getRuntime(); + var totalMemory = rt.totalMemory(); + var freeMemory = rt.freeMemory(); + var usedMemory = totalMemory - freeMemory; + log.info(String.format("Total memory in JVM: %.2f MB", totalMemory / (1024.0 * 1024))); + log.info(String.format("- Free: %.2f MB", freeMemory / (1024.0 * 1024))); + log.info(String.format("- Used: %.2f MB", usedMemory / (1024.0 * 1024))); + } + + public void logWorld() { + log(world.getChunk(CardinalDirection.WEST), CardinalDirection.WEST); + log(world.getChunk(CardinalDirection.NORTH), CardinalDirection.NORTH); + log(world.getChunk(CardinalDirection.EAST), CardinalDirection.EAST); + log(world.getChunk(CardinalDirection.SOUTH), CardinalDirection.SOUTH); + log(world.getChunk(CardinalDirection.THIS), CardinalDirection.THIS); + } + + public void printPlane() { + printPlane(world, map); + } + + public void printPlane(World world, Graph map) { + var chunk = world.getChunk(CardinalDirection.THIS); + var plane = chunk.getPlane(); + var scale = 10; + var downscaledPlane = new String[CHUNK_SIZE / scale][CHUNK_SIZE / scale]; + var numRows = downscaledPlane.length; + var numCols = downscaledPlane[0].length; + var locationCount = 0; + + // Shrink data into the new array and convert to location names + for (int i = 0; i < CHUNK_SIZE; i++) { + for (int j = 0; j < CHUNK_SIZE; j++) { + if (plane[i][j] > 0) { + downscaledPlane[i / scale][j / scale] = + map.getVertexByValue(Pair.of(i, j), CoordType.CHUNK).getLocation().getName(); + } + } + } + + // Print column numbers + log.info("Visualising: {}", chunk.getSummary()); + StringBuilder sb = new StringBuilder(); + sb.append(" "); + for (int col = 0; col < numCols; col++) { + sb.append("%3d".formatted(col)); + } + log.info(sb.toString()); + + // Print row numbers and array contents + for (int row = 0; row < numRows; row++) { + sb = new StringBuilder(); + sb.append("%2d|".formatted(row)); + for (int col = 0; col < numCols; col++) { + if (downscaledPlane[row][col] == null) { + sb.append(" "); + continue; + } + sb.append( + "%s%s%s".formatted(FMT.CYAN, downscaledPlane[row][col].substring(0, 3), FMT.RESET)); + locationCount++; + } + log.info(sb.toString()); + } + + log.info(""); + log.info("- Locations in this chunk: " + locationCount); + } + + public void logVisitedLocations(Player player) { + log.info("Visited locations:"); + player.getVisitedLocations().forEach(l -> log.info("- " + l.getName())); + } + + public void logLocationsInsideTriggerZone(Player player) { + var vertices = + map.getVertices().stream() + .map(Vertex::getLocation) + .filter(l -> isInsideTriggerZone(l.getCoordinates().getChunk())) + .toList(); + var allPlayerCords = player.getCoordinates(); + var whereNext = nextChunkPosition(allPlayerCords.getChunk()); + var whereNextAllCoords = world.getChunk(whereNext).getCoordinates(); + if (whereNext == CardinalDirection.THIS) { + log.info("Player is not inside any trigger zone of {}", whereNextAllCoords.worldToString()); + } else { + log.info( + "Player is inside trigger zone for chunk {} of {}:", + whereNext.toString().toUpperCase(), + allPlayerCords.worldToString()); + vertices.stream() + .filter(l -> l.getCoordinates().equalTo(whereNextAllCoords.getWorld(), CoordType.WORLD)) + .filter(l -> isInsideTriggerZone(l.getCoordinates().getChunk())) + .forEach(l -> log.info("- " + l.getBriefSummary())); + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/action/debug/Debuggable.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/debug/Debuggable.java new file mode 100644 index 0000000..0488dc4 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/action/debug/Debuggable.java @@ -0,0 +1,6 @@ +package com.hindsight.king_of_castrop_rauxel.action.debug; + +@FunctionalInterface +public interface Debuggable { + void execute(); +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/BasicEnemy.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/BasicEnemy.java new file mode 100644 index 0000000..2f0d93d --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/BasicEnemy.java @@ -0,0 +1,61 @@ +package com.hindsight.king_of_castrop_rauxel.characters; + +import com.hindsight.king_of_castrop_rauxel.encounter.Damage; +import com.hindsight.king_of_castrop_rauxel.encounter.EnemyDetails; +import com.hindsight.king_of_castrop_rauxel.event.Loot; +import com.hindsight.king_of_castrop_rauxel.encounter.DungeonDetails; +import com.hindsight.king_of_castrop_rauxel.utils.NameGenerator; +import com.hindsight.king_of_castrop_rauxel.world.IdBuilder; + +import java.util.Random; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +@ToString(onlyExplicitlyIncluded = true) +public class BasicEnemy implements Combatant { + + private final Random random; + @ToString.Include private final String id; + @ToString.Include private final int level; + @ToString.Include private final Loot loot; + @ToString.Include private final Damage damage; + @ToString.Include private final String name; + @ToString.Include private final DungeonDetails.Type type; + @ToString.Include @Setter private int health; + @Setter private Combatant target; + + public BasicEnemy(EnemyDetails enemyDetails, long seed, NameGenerator nameGenerator) { + this.id = IdBuilder.idFrom(this.getClass()); + this.type = enemyDetails.type(); + this.name = generateName(nameGenerator); + this.level = enemyDetails.level(); + this.loot = enemyDetails.loot(); + this.damage = enemyDetails.damage(); + this.health = enemyDetails.health(); + this.random = new Random(seed); + logResult(); + } + + private String generateName(NameGenerator nameGenerator) { + return nameGenerator.enemyNameFrom(this.getClass(), type); + } + + @Override + public int attack(Combatant target) { + if (target == null) { + return 0; + } + int actualDamage = damage.actual(random); + target.takeDamage(actualDamage); + return actualDamage; + } + + public void logResult() { + log.info("Generated: {}", this); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Combatant.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Combatant.java new file mode 100644 index 0000000..d6c697e --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Combatant.java @@ -0,0 +1,58 @@ +package com.hindsight.king_of_castrop_rauxel.characters; + +import com.hindsight.king_of_castrop_rauxel.cli.CliComponent; +import com.hindsight.king_of_castrop_rauxel.event.Loot; + +public interface Combatant { + + String getId(); + + String getName(); + + default boolean isAlive() { + return getHealth() > 0; + } + + int getHealth(); + + void setHealth(int health); + + int getLevel(); + + Loot getLoot(); + + default boolean hasTarget() { + return getTarget() != null && getTarget().getHealth() > 0; + } + + void setTarget(Combatant target); + + Combatant getTarget(); + + default int attack() { + if (!hasTarget()) { + return 0; + } + return attack(getTarget()); + } + + int attack(Combatant target); + + default void takeDamage(int damage) { + var newHealth = getHealth() - damage; + if (newHealth <= 0) { + setHealth(0); + return; + } + setHealth(newHealth); + } + + default String combatantToString() { + var health = getHealth() > 0 ? ", " + CliComponent.health(getHealth()) + " HP" : ""; + return CliComponent.bold(getName()) + + " (Level " + + CliComponent.level(getLevel()) + + health + + ")"; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Inhabitant.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Inhabitant.java new file mode 100644 index 0000000..bc4ff74 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Inhabitant.java @@ -0,0 +1,98 @@ +package com.hindsight.king_of_castrop_rauxel.characters; + +import com.hindsight.king_of_castrop_rauxel.event.Event; +import com.hindsight.king_of_castrop_rauxel.location.PointOfInterest; +import com.hindsight.king_of_castrop_rauxel.utils.Generators; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import com.hindsight.king_of_castrop_rauxel.world.IdBuilder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@EqualsAndHashCode(exclude = {"generators", "primaryEvent", "secondaryEvents"}) +public class Inhabitant implements Npc { + + private final Generators generators; + private final Random random; + + @Getter private String id; + @Getter private String firstName; + @Getter private String lastName; + @Getter private String fullName; + @Getter private PointOfInterest home; + @Getter private Event primaryEvent; + @Getter private final List secondaryEvents = new ArrayList<>(); + + public Inhabitant(Random random, Generators generators) { + this.random = random; + this.generators = generators; + load(); + logResult(); + } + + @Override + public void load() { + id = IdBuilder.idFrom(this.getClass()); + firstName = generators.nameGenerator().npcFirstNameFrom(Inhabitant.class); + lastName = generators.nameGenerator().npcLastNameFrom(Inhabitant.class); + fullName = firstName + " " + lastName; + } + + @Override + public String getName() { + return firstName + " " + lastName; + } + + /** Set or resets (i.e. sets to null) the home of this inhabitant. */ + @Override + public void setHome(PointOfInterest home) { + this.home = home; + log.info("Set home of '{}' to: {}", this.fullName, home); + } + + @Override + public void loadPrimaryEvent() { + var randomInt = random.nextInt(Event.Type.values().length); + var eventGen = generators.eventGenerator(); + var eventCandidate = + switch (randomInt) { + case 0 -> eventGen.singleStepDialogue(this); + case 1 -> eventGen.multiStepDialogue(this); + case 2 -> eventGen.deliveryEvent(this); + default -> throw new IllegalStateException( + "You forgot to implement every event type in the Inhabitant class: " + randomInt); + }; + primaryEvent = eventCandidate == null ? eventGen.singleStepDialogue(this) : eventCandidate; + } + + @Override + public void addSecondaryEvent(Event event) { + if (event.equals(primaryEvent)) { + return; + } + secondaryEvents.add(event); + } + + @Override + public void logResult() { + log.debug("Generated: {}", this); + } + + @Override + public String toString() { + return fullName + + "(id=" + + id + + ", home=" + + home.getName() + + ", event=" + + primaryEvent + + ", secondaryEvents=" + + secondaryEvents + + ')'; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Npc.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Npc.java new file mode 100644 index 0000000..8f8abba --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Npc.java @@ -0,0 +1,30 @@ +package com.hindsight.king_of_castrop_rauxel.characters; + +import com.hindsight.king_of_castrop_rauxel.event.Event; +import com.hindsight.king_of_castrop_rauxel.location.PointOfInterest; + +import java.util.List; + +public interface Npc { + String getId(); + + String getName(); + + String getFirstName(); + + PointOfInterest getHome(); + + void setHome(PointOfInterest home); + + Event getPrimaryEvent(); + + List getSecondaryEvents(); + + void addSecondaryEvent(Event event); + + void load(); + + void loadPrimaryEvent(); + + void logResult(); +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Player.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Player.java new file mode 100644 index 0000000..9bff3cd --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Player.java @@ -0,0 +1,130 @@ +package com.hindsight.king_of_castrop_rauxel.characters; + +import com.hindsight.king_of_castrop_rauxel.encounter.Damage; +import com.hindsight.king_of_castrop_rauxel.event.Event; +import com.hindsight.king_of_castrop_rauxel.event.Loot; +import com.hindsight.king_of_castrop_rauxel.event.Reward; +import com.hindsight.king_of_castrop_rauxel.location.Location; +import com.hindsight.king_of_castrop_rauxel.location.PointOfInterest; +import com.hindsight.king_of_castrop_rauxel.world.Coordinates; + +import java.util.*; + +import com.hindsight.king_of_castrop_rauxel.world.IdBuilder; +import lombok.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.*; + +@Slf4j +@Getter +public class Player implements Visitor, Combatant { + + private final String id; + private final String name; + private final Set visitedLocations = new LinkedHashSet<>(); + private final List events = new ArrayList<>(); + private final Coordinates coordinates; + private final Pair startCoordinates; + private final Random random = new Random(); + private int gold = PLAYER_STARTING_GOLD; + private int health = PLAYER_STARTING_MAX_HEALTH; + private int maxHealth = PLAYER_STARTING_MAX_HEALTH; + private int experience = 0; + private int level = 1; + private Damage damage = PLAYER_STARTING_DAMAGE; + private State previousState = State.AT_POI; + private State state = State.AT_POI; + private Location currentLocation; + private PointOfInterest currentPoi; + @Setter private Event currentEvent; + @Setter private Combatant target; + + public enum State { + CHOOSING_POI, + AT_POI, + IN_DIALOGUE, + IN_COMBAT, + DEBUGGING + } + + public Player( + String name, @NonNull Location currentLocation, Pair worldCoords) { + this.name = name; + this.coordinates = new Coordinates(worldCoords, currentLocation.getCoordinates().getChunk()); + this.startCoordinates = coordinates.getGlobal(); + this.id = IdBuilder.idFrom(this.getClass(), coordinates); + this.currentLocation = currentLocation; + this.currentPoi = currentLocation.getDefaultPoi(); + visitedLocations.add(currentLocation); + currentLocation.addVisitor(this); + } + + public void setCurrentPoi(PointOfInterest currentPoi) { + var location = currentPoi.getParent(); + this.currentLocation = location; + this.currentPoi = currentPoi; + this.coordinates.setTo(location.getCoordinates().getGlobal()); + visitedLocations.add(location); + location.addVisitor(this); + } + + public void addEvent(Event event) { + events.add(event); + } + + public void addExperience(int amount) { + this.experience += amount; + if (experience >= PLAYER_EXPERIENCE_TO_LEVEL_UP) { + level++; + experience = experience % PLAYER_EXPERIENCE_TO_LEVEL_UP; + } + } + + public void changeGoldBy(int amount) { + this.gold += amount; + } + + public void changeHealthBy(int health) { + this.health = Math.max(0, Math.min(maxHealth, this.health + health)); + } + + public void setHealth(int health) { + this.health = health; + } + + public void changeMaxHealthBy(int maxHealth) { + this.maxHealth = Math.max(PLAYER_STARTING_MAX_HEALTH, this.maxHealth + maxHealth); + } + + @Override + public Loot getLoot() { + gold = 0; + return new Loot(List.of(new Reward(Reward.Type.GOLD, gold))); + } + + @Override + public int attack(Combatant target) { + if (target == null) { + return 0; + } + var actualDamage = damage.actual(random); + target.takeDamage(actualDamage); + return actualDamage; + } + + public void setState(State state) { + this.previousState = this.state; + this.state = state; + log.info("Updating CLI state to {}", state); + } + + public boolean hasCurrentEvent() { + return currentEvent != null; + } + + public List getActiveEvents() { + return events.stream().filter(e -> e.getEventState() == Event.State.ACTIVE).toList(); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Visitor.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Visitor.java new file mode 100644 index 0000000..2f3d0db --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/characters/Visitor.java @@ -0,0 +1,4 @@ +package com.hindsight.king_of_castrop_rauxel.characters; + +public interface Visitor { +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/CliComponent.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/CliComponent.java new file mode 100644 index 0000000..5e8f0a7 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/CliComponent.java @@ -0,0 +1,216 @@ +package com.hindsight.king_of_castrop_rauxel.cli; + +import static java.lang.System.out; + +import com.hindsight.king_of_castrop_rauxel.configuration.AppProperties; +import com.hindsight.king_of_castrop_rauxel.event.Reward; +import com.hindsight.king_of_castrop_rauxel.items.Buyable; +import com.hindsight.king_of_castrop_rauxel.location.PointOfInterest; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class CliComponent { + + public static final boolean WINDOWS = System.getProperty("os.name").contains("Windows"); + public static final String LABEL_FORMAT = " %s(%s)%s"; + public static final String BUYABLE_ACTION_FORMAT = "%s - %s (%s gold)"; + + public enum FMT { + RESET("\033[0m"), + + // Normal colour font + BLACK("\033[0;30m"), + RED("\033[0;31m"), + GREEN("\033[0;32m"), + YELLOW("\033[0;33m"), + BLUE("\033[0;34m"), + MAGENTA("\033[0;35m"), + CYAN("\033[0;36m"), + WHITE("\033[0;37m"), + DEFAULT("\033[0;39m"), + + // Bold font + BLACK_BOLD("\033[1;30m"), + RED_BOLD("\033[1;31m"), + GREEN_BOLD("\033[1;32m"), + YELLOW_BOLD("\033[1;33m"), + BLUE_BOLD("\033[1;34m"), + MAGENTA_BOLD("\033[1;35m"), + CYAN_BOLD("\033[1;36m"), + WHITE_BOLD("\033[1;37m"), + DEFAULT_BOLD("\033[1;39m"), + + // Underlined font + BLACK_UNDERLINED("\033[4;30m"), + RED_UNDERLINED("\033[4;31m"), + GREEN_UNDERLINED("\033[4;32m"), + YELLOW_UNDERLINED("\033[4;33m"), + BLUE_UNDERLINED("\033[4;34m"), + MAGENTA_UNDERLINED("\033[4;35m"), + CYAN_UNDERLINED("\033[4;36m"), + WHITE_UNDERLINED("\033[4;37m"), + + // Background colours + BLACK_BACKGROUND("\033[40m"), + RED_BACKGROUND("\033[41m"), + GREEN_BACKGROUND("\033[42m"), + YELLOW_BACKGROUND("\033[43m"), + BLUE_BACKGROUND("\033[44m"), + MAGENTA_BACKGROUND("\033[45m"), + CYAN_BACKGROUND("\033[46m"), + WHITE_BACKGROUND("\033[47m"), + + // High intensity font colours + BLACK_BRIGHT("\033[0;90m"), + RED_BRIGHT("\033[0;91m"), + GREEN_BRIGHT("\033[0;92m"), + YELLOW_BRIGHT("\033[0;93m"), + BLUE_BRIGHT("\033[0;94m"), + MAGENTA_BRIGHT("\033[0;95m"), + CYAN_BRIGHT("\033[0;96m"), + WHITE_BRIGHT("\033[0;97m"), + + // Bold + high intensity font colours + BLACK_BOLD_BRIGHT("\033[1;90m"), + RED_BOLD_BRIGHT("\033[1;91m"), + GREEN_BOLD_BRIGHT("\033[1;92m"), + YELLOW_BOLD_BRIGHT("\033[1;93m"), + BLUE_BOLD_BRIGHT("\033[1;94m"), + MAGENTA_BOLD_BRIGHT("\033[1;95m"), + CYAN_BOLD_BRIGHT("\033[1;96m"), + WHITE_BOLD_BRIGHT("\033[1;97m"), + + // High intensity backgrounds colours + BLACK_BACKGROUND_BRIGHT("\033[0;100m"), + RED_BACKGROUND_BRIGHT("\033[0;101m"), + GREEN_BACKGROUND_BRIGHT("\033[0;102m"), + YELLOW_BACKGROUND_BRIGHT("\033[0;103m"), + BLUE_BACKGROUND_BRIGHT("\033[0;104m"), + MAGENTA_BACKGROUND_BRIGHT("\033[0;105m"), + CYAN_BACKGROUND_BRIGHT("\033[0;106m"), + WHITE_BACKGROUND_BRIGHT("\033[0;107m"); + + private final String code; + + FMT(String code) { + this.code = code; + } + + @Override + public String toString() { + return code; + } + } + + public static void clearConsole() { + try { + if (WINDOWS) { + new ProcessBuilder("cmd.exe", "/c", "cls").inheritIO().start().waitFor(); + } else { + out.print("\033[H\033[2J"); + out.flush(); + } + } catch (Exception e) { + log.info("Failed to clear console", e); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + } + } + + public static String label(String label, FMT format) { + return LABEL_FORMAT.formatted(format, label, FMT.RESET); + } + + public static String label(CliComponent.Type type) { + return switch (type) { + case LOCATION -> label("Location", FMT.BLUE); + case QUEST -> label("Quest", FMT.BLUE); + case DIALOGUE -> label("Dialogue", FMT.BLUE); + }; + } + + public static String label(PointOfInterest.Type type) { + return switch (type) { + case MAIN_SQUARE -> label("Main Square", toColour(type)); + case SHOP -> LABEL_FORMAT.formatted(toColour(type), "Shop", FMT.RESET); + case DUNGEON -> LABEL_FORMAT.formatted(toColour(type), "Dungeon", FMT.RESET); + default -> ""; + }; + } + + public static FMT toColour(Reward.Type type) { + return switch (type) { + case GOLD -> FMT.YELLOW_BOLD; + case EXPERIENCE -> FMT.BLUE_BOLD; + }; + } + + private static FMT toColour(PointOfInterest.Type type) { + return switch (type) { + case MAIN_SQUARE, DUNGEON, SHOP -> FMT.BLUE; + default -> FMT.WHITE_BOLD; + }; + } + + public static String level(int level) { + return FMT.MAGENTA + String.valueOf(level) + FMT.RESET; + } + + public static String health(int health) { + return FMT.RED + String.valueOf(health) + FMT.RESET; + } + + public static String gold(int gold) { + return FMT.YELLOW + String.valueOf(gold) + FMT.RESET; + } + + public static String buyable(Buyable b) { + return BUYABLE_ACTION_FORMAT.formatted(b.getName(), b.getDescription(), gold(b.getBasePrice())); + } + + public static String bold(String text) { + return FMT.WHITE_BOLD_BRIGHT + text + FMT.RESET; + } + + public static String error(String text) { + return FMT.RED + text + FMT.RESET; + } + + // TODO: Fix awaitEnterKeyPress() when called in JAR with multiple text lines in dialogue + @SuppressWarnings("ResultOfMethodCallIgnored") + public static void awaitEnterKeyPress() { + try { + var message = "Press enter to continue..."; + out.print(message); + System.in.read(); + removeString(message, true); + } catch (IOException e) { + log.error("Could not read input from console", e); + } + } + + public static void removeString(String toRemove, boolean previousLine) { + if (Boolean.FALSE.equals(AppProperties.getIsRunningAsJar())) { + out.println(); + return; + } + if (previousLine) { + out.print("\033[F"); + } + out.print("\r"); + for (int i = 0; i < toRemove.length(); i++) { + out.print(" "); + } + out.println(); + } + + public enum Type { + LOCATION, + QUEST, + DIALOGUE, + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/CliGame.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/CliGame.java new file mode 100644 index 0000000..cc6fae2 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/CliGame.java @@ -0,0 +1,63 @@ +package com.hindsight.king_of_castrop_rauxel.cli; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.cli.loop.*; +import com.hindsight.king_of_castrop_rauxel.configuration.EnvironmentResolver; +import com.hindsight.king_of_castrop_rauxel.graphs.Graph; +import com.hindsight.king_of_castrop_rauxel.location.AbstractLocation; +import com.hindsight.king_of_castrop_rauxel.world.World; +import java.util.ArrayList; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor(onConstructor = @__(@Autowired), access = AccessLevel.PRIVATE) +public class CliGame { + + private final EnvironmentResolver environmentResolver; + private final World world; + private final Graph map; + private final ChoosePoiLoop choosePoiLoop; + private final PoiLoop poiLoop; + private final DialogueLoop dialogueLoop; + private final CombatLoop combatLoop; + private final DebugLoop debugLoop; + private Player player; + + @SuppressWarnings("InfiniteLoopStatement") + public void play() { + if (environmentResolver.isNotCli()) { + log.info("Not running in CLI mode, CLI game will not be started"); + return; + } + var actions = new ArrayList(); + initialise(); + while (true) { + switch (player.getState()) { + case CHOOSING_POI -> choosePoiLoop.execute(actions); + case AT_POI -> poiLoop.execute(actions); + case IN_DIALOGUE -> dialogueLoop.execute(actions); + case IN_COMBAT -> combatLoop.execute(actions); + case DEBUGGING -> debugLoop.execute(actions); + } + } + } + + private void initialise() { + world.generateChunk(world.getCentreCoords(), map); + world.setCurrentChunk(world.getCentreCoords()); + var startLocation = world.getCurrentChunk().getCentralLocation(world, map); + var worldCoordinates = world.getCurrentChunk().getCoordinates().getWorld(); + player = new Player("Traveller", startLocation, worldCoordinates); + dialogueLoop.initialise(player); + poiLoop.initialise(player); + choosePoiLoop.initialise(player); + debugLoop.initialise(player); + combatLoop.initialise(player); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/NewGame.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/NewGame.java deleted file mode 100644 index be0f2f7..0000000 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/NewGame.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.hindsight.king_of_castrop_rauxel.cli; - -import com.hindsight.king_of_castrop_rauxel.location.Location; -import com.hindsight.king_of_castrop_rauxel.location.Settlement; -import com.hindsight.king_of_castrop_rauxel.player.Player; - -public class NewGame { - - private Player player; - - public void start() { - var name = "Player"; - var startLocation = new Settlement(); - this.player = new Player(name, startLocation); - play(); - } - - private void play() { - System.out.printf("%nWelcome to King of Castrop-Rauxel, %s!%n%n", player.getName()); - System.out.printf( - "STATS: [ Gold: %s | Level: %s | Age: %s | Activity points left: %s ]%n", - player.getGold(), player.getLevel(), player.getAge(), player.getActivityPoints()); - Location currentLocation = player.getCurrentLocation(); - System.out.printf("CURRENT LOCATION: %s%n%n", currentLocation.getSummary()); - System.out.printf("What do you want to do?%n"); - player - .getCurrentLocation() - .getAvailableActions() - .forEach(action -> System.out.printf("%s%n", action.getName())); - } -} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/ProgressBar.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/ProgressBar.java new file mode 100644 index 0000000..d704aaf --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/ProgressBar.java @@ -0,0 +1,58 @@ +package com.hindsight.king_of_castrop_rauxel.cli; + +import com.hindsight.king_of_castrop_rauxel.configuration.AppProperties; +import com.hindsight.king_of_castrop_rauxel.location.Location; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.SPEED_MODIFIER; +import static java.lang.System.out; + +@Slf4j +@Component +@RequiredArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public class ProgressBar { + + // TODO: Allow interrupting (returning progress %) and resuming later (accepting progress %) + public static void displayProgress(Location from, Location to) { + if (from.equals(to)) { + return; + } + var progressBarWidth = 100; + var totalSteps = 100; + var millisecondsPerStep = from.distanceTo(to) * SPEED_MODIFIER; + prepareCli(); + for (int step = 0; step <= totalSteps; step++) { + var progress = (float) step / totalSteps; + var filledWidth = (int) (progress * progressBarWidth); + var emptyWidth = progressBarWidth - filledWidth; + var progressPercentage = progress * 100; + var progressBar = + from.getName() + + " [" + + ">".repeat(Math.max(0, filledWidth)) + + " ".repeat(Math.max(0, emptyWidth)) + + "] " + + to.getName() + + " - " + + (int) progressPercentage + + "%"; + out.print("\r" + progressBar); + try { + Thread.sleep((long) millisecondsPerStep); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + private static void prepareCli() { + if (Boolean.TRUE.equals(AppProperties.getIsRunningAsJar())) { + CliComponent.clearConsole(); + out.printf("%n"); + } else { + out.printf("%n%n"); + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/combat/Encounter.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/combat/Encounter.java new file mode 100644 index 0000000..84528e9 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/combat/Encounter.java @@ -0,0 +1,216 @@ +package com.hindsight.king_of_castrop_rauxel.cli.combat; + +import com.hindsight.king_of_castrop_rauxel.characters.Combatant; +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.cli.CliComponent; +import com.hindsight.king_of_castrop_rauxel.event.Loot; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import static com.hindsight.king_of_castrop_rauxel.cli.CliComponent.*; +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.DELAY_IN_MS; +import static java.lang.System.out; + +public class Encounter { + + private final Random random = new Random(); + private final List initialAllies; + private final List initialEnemies; + private final List attackers = new ArrayList<>(); + private final List defenders = new ArrayList<>(); + private final Loot loot = new Loot(); + private Player player; + private boolean isOver; + private boolean isAttacker; + + public Encounter(List allies, List enemies) { + this.initialAllies = allies; + this.initialEnemies = enemies; + } + + public void execute(Player player, boolean isAttacker) { + this.player = player; + this.isAttacker = isAttacker; + initialise(); + complete(); + printWrapUp(); + loot.give(player); + } + + private void initialise() { + if (isAttacker) { + attackers.add(player); + defenders.addAll(initialEnemies); + addAlliesTo(attackers); + } else { + defenders.add(player); + attackers.addAll(initialEnemies); + addAlliesTo(defenders); + } + printKickOff(); + } + + private void addAlliesTo(List combatants) { + if (initialAllies != null) { + combatants.addAll(initialAllies); + } + } + + private void complete() { + while (!isOver) { + attackAndEvaluate(attackers, defenders); + attackAndEvaluate(defenders, attackers); + } + } + + private void attackAndEvaluate(List attackingGroup, List defendingGroup) { + for (var attacker : attackingGroup) { + try { + Thread.sleep(DELAY_IN_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + getTarget(attacker, defendingGroup); + if (isOver) { + return; + } + var damage = attacker.attack(); + printAttack(attacker, attacker.getTarget(), damage); + evaluateAttack(attacker.getTarget(), defendingGroup); + } + } + + private void evaluateAttack(Combatant target, List defendingGroup) { + if (target.isAlive()) { + return; + } + var droppedLoot = target.getLoot(); + loot.add(droppedLoot); + printDeath(target, droppedLoot); + if (isPlayer(target)) { + isOver = true; + return; + } + defendingGroup.remove(target); + } + + private void getTarget(Combatant combatant, List opposingCombatants) { + if (combatant.hasTarget() && combatant.getTarget().isAlive()) { + return; + } + var possibleTargets = new ArrayList<>(opposingCombatants); + while (!combatant.hasTarget() && !possibleTargets.isEmpty()) { + var target = selectNewTarget(possibleTargets); + if (target.isAlive()) { + setNewTarget(combatant, target); + return; + } + possibleTargets.remove(target); + } + isOver = true; + combatant.setTarget(null); + } + + private Combatant selectNewTarget(List possibleTargets) { + return possibleTargets.get(random.nextInt(possibleTargets.size())); + } + + private void setNewTarget(Combatant combatant, Combatant target) { + combatant.setTarget(target); + if (!target.hasTarget()) { + target.setTarget(combatant); + } + } + + private boolean isPlayer(Combatant combatant) { + return player.getId().equals(combatant.getId()); + } + + private boolean isEnemy(Combatant combatant) { + return initialEnemies.contains(combatant); + } + + private void printKickOff() { + out.printf("%nYou %s%n%n", isAttacker ? "have the initiative." : "are being surprised."); + printCombatants(attackers, "Attacker(s)"); + printCombatants(defenders, "Defender(s)"); + out.printf("%nThe fight has started.%n%n"); + CliComponent.awaitEnterKeyPress(); + } + + private void printAttack(Combatant attacker, Combatant target, int damage) { + var attackerColour = isPlayer(attacker) ? FMT.GREEN_BOLD : FMT.MAGENTA_BOLD; + var targetColour = isPlayer(target) ? FMT.GREEN_BOLD : FMT.MAGENTA_BOLD; + if (isEnemy(attacker)) { + out.printf( + "- %s%s%s is attacked by %s%s%s %s-%d%s -> %s%d%s HP%n", + targetColour, + target.getName().toUpperCase(), + FMT.RESET, + attackerColour, + attacker.getName().toUpperCase(), + FMT.RESET, + FMT.RED, + damage, + FMT.RESET, + FMT.GREEN, + target.getHealth(), + FMT.RESET); + return; + } + out.printf( + "- %s%s%s attacks %s%s%s %s-%d%s -> %s%d%s HP%n", + attackerColour, + attacker.getName().toUpperCase(), + FMT.RESET, + targetColour, + target.getName().toUpperCase(), + FMT.RESET, + FMT.GREEN, + damage, + FMT.RESET, + FMT.RED, + target.getHealth(), + FMT.RESET); + } + + private void printCombatants(List combatants) { + printCombatants(combatants, ""); + } + + private void printCombatants(List combatants, String groupName) { + var stringBuilder = new StringBuilder(); + if (!groupName.isEmpty()) { + stringBuilder.append(groupName).append(": "); + } + for (int i = 0; i < combatants.size(); i++) { + stringBuilder.append(combatants.get(i).combatantToString()); + if (i < combatants.size() - 1) { + stringBuilder.append(", "); + } + } + out.println(stringBuilder); + } + + private void printDeath(Combatant combatant, Loot loot) { + var colour = isPlayer(combatant) ? FMT.GREEN_BOLD : FMT.MAGENTA_BOLD; + out.printf( + "- %s%s%s has died, dropping %s%n", + colour, combatant.getName().toUpperCase(), FMT.RESET, loot); + } + + private void printWrapUp() { + out.printf("%nThe fight is over!%n%n"); + if (player.isAlive()) { + out.print(CliComponent.bold("You have won!") + " You have defeated: "); + printCombatants(initialEnemies); + out.printf( + "You have gained: %s. You have %s HP left.%n", + loot, CliComponent.health(player.getHealth())); + } else { + out.print(CliComponent.bold("You have died!") + " Game over. Thanks for playing!"); + System.exit(0); + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/AbstractLoop.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/AbstractLoop.java new file mode 100644 index 0000000..c3cf49f --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/AbstractLoop.java @@ -0,0 +1,151 @@ +package com.hindsight.king_of_castrop_rauxel.cli.loop; + +import static com.hindsight.king_of_castrop_rauxel.cli.CliComponent.*; +import static java.lang.System.out; + +import com.google.common.base.Strings; +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.cli.CliComponent; +import com.hindsight.king_of_castrop_rauxel.configuration.AppProperties; +import de.codeshelf.consoleui.prompt.ConsolePrompt; +import de.codeshelf.consoleui.prompt.ListResult; +import java.io.IOException; +import java.util.List; +import java.util.Scanner; +import jline.TerminalFactory; + +public abstract class AbstractLoop { + + protected Player player; + + protected abstract Scanner getScanner(); + + protected abstract AppProperties getAppProperties(); + + public void initialise(Player player) { + this.player = player; + } + + public abstract void execute(List actions); + + protected void printHeaders(boolean showPoi) { + if (getAppProperties().getEnvironment().clearConsole()) { + clearConsole(); + } + out.printf( + "%sSTATS [ Gold: %s%s%s%s | Level: %s%s%s%s | Experience: %s%s%s%s | Health Points: %s%s%s%s ]%s%n", + FMT.DEFAULT_BOLD, + FMT.YELLOW_BOLD, + player.getGold(), + FMT.RESET, + FMT.DEFAULT_BOLD, + FMT.MAGENTA_BOLD, + player.getLevel(), + FMT.RESET, + FMT.DEFAULT_BOLD, + FMT.BLUE_BOLD, + player.getExperience(), + FMT.RESET, + FMT.DEFAULT_BOLD, + FMT.RED_BOLD, + player.getHealth(), + FMT.RESET, + FMT.DEFAULT_BOLD, + FMT.RESET); + var currentLocation = player.getCurrentLocation(); + out.printf( + "%sCURRENT LOCATION: %s%s%n%n", + FMT.DEFAULT_BOLD, currentLocation.getFullSummary(), FMT.RESET); + if (showPoi) { + out.printf( + "%sYou are at: %s.%s ", FMT.DEFAULT_BOLD, player.getCurrentPoi().getName(), FMT.RESET); + } + } + + protected void promptPlayer(List actions, String message) { + if (actions.isEmpty()) { + return; + } + if (getAppProperties().getEnvironment().useConsoleUi()) { + useConsoleUi(actions, message); + return; + } + useSystemOut(actions, message); + } + + @SuppressWarnings("CallToPrintStackTrace") + private void useConsoleUi(List actions, String message) { + out.println(); + message = message == null ? "Your response:" : getDescription() + message; + var prompt = new ConsolePrompt(); + var promptBuilder = prompt.getPromptBuilder(); + var listPrompt = promptBuilder.createListPrompt(); + listPrompt.name("prompt").message(message); + actions.forEach(a -> listPrompt.newItem(String.valueOf(a.getIndex())).text(a.getName()).add()); + listPrompt.addPrompt(); + try { + var result = prompt.prompt(promptBuilder.build()); + var selectedIndex = ((ListResult) result.get("prompt")).getSelectedId(); + var action = getValidActionOrThrow(Integer.parseInt(selectedIndex), actions); + action.execute(player); + } catch (IOException e) { + e.printStackTrace(); + } finally { + try { + TerminalFactory.get().restore(); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + private void useSystemOut(List actions, String message) { + if (message != null) { + out.printf("%s%s%s%s%n", FMT.DEFAULT_BOLD, getDescription(), message, FMT.RESET); + } + actions.forEach(a -> out.println(a.print())); + out.printf("%n%s>%s ", FMT.WHITE_BOLD_BRIGHT, FMT.RESET); + takeAction(actions); + } + + private String getDescription() { + if (player.getState() != Player.State.AT_POI) { + return ""; + } + var poi = player.getCurrentPoi(); + var hasNoActions = poi.getAvailableActions().isEmpty(); + var text = hasNoActions ? "There is nothing to do here. " : " "; + var description = poi.getDescription(); + return Strings.isNullOrEmpty(description) ? text : description + text; + } + + protected void takeAction(List actions) { + if (actions.isEmpty()) { + return; + } + var anyInput = getScanner().next(); + try { + var validInput = Integer.parseInt(anyInput); + var action = getValidActionOrThrow(validInput, actions); + action.execute(player); + } catch (NumberFormatException e) { + var errorMessage = "Invalid choice, try again...%n"; + out.printf(CliComponent.error(errorMessage)); + CliComponent.awaitEnterKeyPress(); + recoverInvalidAction(); + } + out.println(); + } + + private Action getValidActionOrThrow(Integer validInput, List actions) { + return actions.stream() + .filter(a -> a.getIndex() == validInput) + .findFirst() + .orElseThrow(() -> new NumberFormatException("Couldn't find input in actions")); + } + + protected void recoverInvalidAction() { + // Empty by default but can be overridden, e.g. to reset any quest progression + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/ChoosePoiLoop.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/ChoosePoiLoop.java new file mode 100644 index 0000000..c5d94b5 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/ChoosePoiLoop.java @@ -0,0 +1,40 @@ +package com.hindsight.king_of_castrop_rauxel.cli.loop; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.action.ActionHandler; +import com.hindsight.king_of_castrop_rauxel.configuration.AppProperties; +import com.hindsight.king_of_castrop_rauxel.game.GameHandler; +import java.util.List; +import java.util.Scanner; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor(onConstructor = @__(@Autowired)) +public class ChoosePoiLoop extends AbstractLoop { + + private final ActionHandler actionHandler; + private final GameHandler gameHandler; + @Getter private final Scanner scanner; + @Getter private final AppProperties appProperties; + + @Override + public void execute(List actions) { + printHeaders(true); + prepareActions(actions); + promptPlayer(actions, "Where would you like to go?"); + postProcess(); + } + + private void prepareActions(List actions) { + actionHandler.getChoosePoiActions(player, actions); + } + + private void postProcess() { + gameHandler.updateWorld(player); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/CombatLoop.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/CombatLoop.java new file mode 100644 index 0000000..cd6dce5 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/CombatLoop.java @@ -0,0 +1,32 @@ +package com.hindsight.king_of_castrop_rauxel.cli.loop; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.action.ActionHandler; +import com.hindsight.king_of_castrop_rauxel.configuration.AppProperties; +import java.util.List; +import java.util.Scanner; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor(onConstructor = @__(@Autowired)) +public class CombatLoop extends AbstractLoop { + + private final ActionHandler actionHandler; + @Getter private final Scanner scanner; + @Getter private final AppProperties appProperties; + + @Override + public void execute(List actions) { + prepareActions(actions); + promptPlayer(actions, "What now?"); + } + + private void prepareActions(List actions) { + actionHandler.getCombatActions(player, actions); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/DebugLoop.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/DebugLoop.java new file mode 100644 index 0000000..fb32938 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/DebugLoop.java @@ -0,0 +1,40 @@ +package com.hindsight.king_of_castrop_rauxel.cli.loop; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.action.ActionHandler; +import com.hindsight.king_of_castrop_rauxel.configuration.AppProperties; +import com.hindsight.king_of_castrop_rauxel.game.GameHandler; +import java.util.List; +import java.util.Scanner; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor(onConstructor = @__(@Autowired)) +public class DebugLoop extends AbstractLoop { + + private final ActionHandler actionHandler; + private final GameHandler gameHandler; + @Getter private final Scanner scanner; + @Getter private final AppProperties appProperties; + + @Override + public void execute(List actions) { + printHeaders(false); + prepareActions(actions); + promptPlayer(actions, "What would you like to do?"); + postProcess(); + } + + private void prepareActions(List actions) { + actionHandler.getDebugActions(player, actions); + } + + private void postProcess() { + gameHandler.updateWorld(player); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/DialogueLoop.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/DialogueLoop.java new file mode 100644 index 0000000..4ea543d --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/DialogueLoop.java @@ -0,0 +1,71 @@ +package com.hindsight.king_of_castrop_rauxel.cli.loop; + +import static java.lang.System.out; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.action.ActionHandler; +import com.hindsight.king_of_castrop_rauxel.cli.CliComponent; +import com.hindsight.king_of_castrop_rauxel.configuration.AppProperties; +import com.hindsight.king_of_castrop_rauxel.game.GameHandler; +import java.util.List; +import java.util.Scanner; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor(onConstructor = @__(@Autowired)) +public class DialogueLoop extends AbstractLoop { + + private final ActionHandler actionHandler; + private final GameHandler gameHandler; + @Getter private final Scanner scanner; + @Getter private final AppProperties appProperties; + + @Override + public void execute(List actions) { + printInteraction(); + prepareActions(actions); + promptPlayer(actions, null); + postProcess(); + } + + private void printInteraction() { + var dialogue = player.getCurrentEvent(); + if (dialogue.hasCurrentInteraction()) { + // TODO: Fix dialogue.isBeginningOfDialogue() as it doesn't work when accepting reward + if (dialogue.isBeginningOfDialogue() && appProperties.getEnvironment().clearConsole()) { + CliComponent.clearConsole(); + } + out.printf( + "%s%s%s%s: %s%n%n", + CliComponent.FMT.BLACK, + CliComponent.FMT.WHITE_BACKGROUND, + dialogue.getCurrentNpc().getName(), + CliComponent.FMT.RESET, + dialogue.getCurrentInteraction().getText().formatted()); + } + } + + private void prepareActions(List actions) { + if (player.getCurrentEvent().getCurrentActions().isEmpty()) { + actionHandler.getNone(actions); + CliComponent.awaitEnterKeyPress(); + return; + } + actionHandler.getDialogueActions(player, actions); + } + + @Override + protected void recoverInvalidAction() { + log.info("Invalid action - recovering"); + player.getCurrentEvent().rewindBy(1); + } + + private void postProcess() { + gameHandler.updateCurrentEventDialogue(player); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/PoiLoop.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/PoiLoop.java new file mode 100644 index 0000000..2dcc801 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/cli/loop/PoiLoop.java @@ -0,0 +1,40 @@ +package com.hindsight.king_of_castrop_rauxel.cli.loop; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.action.ActionHandler; +import com.hindsight.king_of_castrop_rauxel.configuration.AppProperties; +import com.hindsight.king_of_castrop_rauxel.game.GameHandler; +import java.util.List; +import java.util.Scanner; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor(onConstructor = @__(@Autowired)) +public class PoiLoop extends AbstractLoop { + + private final ActionHandler actionHandler; + private final GameHandler gameHandler; + @Getter private final Scanner scanner; + @Getter private final AppProperties appProperties; + + @Override + public void execute(List actions) { + printHeaders(true); + prepareActions(actions); + promptPlayer(actions, "What's next?"); + postProcess(); + } + + private void prepareActions(List actions) { + actionHandler.getThisPoiActions(player, actions); + } + + private void postProcess() { + gameHandler.updateWorld(player); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/AppConfiguration.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/AppConfiguration.java new file mode 100644 index 0000000..a808e20 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/AppConfiguration.java @@ -0,0 +1,76 @@ +package com.hindsight.king_of_castrop_rauxel.configuration; + +import com.hindsight.king_of_castrop_rauxel.graphs.Graph; +import com.hindsight.king_of_castrop_rauxel.items.ConsumableService; +import com.hindsight.king_of_castrop_rauxel.location.AbstractLocation; +import com.hindsight.king_of_castrop_rauxel.utils.*; +import com.hindsight.king_of_castrop_rauxel.utils.BasicTerrainGenerator; +import com.hindsight.king_of_castrop_rauxel.utils.TerrainGenerator; +import com.hindsight.king_of_castrop_rauxel.world.World; +import com.hindsight.king_of_castrop_rauxel.world.WorldHandler; +import java.util.Scanner; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class AppConfiguration { + + private final ConsumableService consumableService; + + @Bean + public Scanner scanner() { + return new Scanner(System.in); + } + + @Bean + public NameGenerator nameGenerator() { + return new BasicNameGenerator(folderReader()); + } + + @Bean + public EventGenerator eventGenerator() { + return new BasicEventGenerator(folderReader()); + } + + @Bean + public TerrainGenerator terrainGenerator() { + return new BasicTerrainGenerator(); + } + + @Bean + public Generators generators() { + return new Generators(nameGenerator(), eventGenerator(), terrainGenerator()); + } + + @Bean + public DataServices dataServices() { + return new DataServices(consumableService); + } + + @Bean + public FolderReader folderReader() { + return new FolderReader(); + } + + @Bean + public AppProperties appProperties() { + return new AppProperties(); + } + + @Bean + public World world() { + return new World(appProperties(), worldBuilder()); + } + + @Bean + public Graph map() { + return new Graph<>(true); + } + + @Bean + public WorldHandler worldBuilder() { + return new WorldHandler(map(), generators(), dataServices()); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/AppConstants.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/AppConstants.java new file mode 100644 index 0000000..652e700 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/AppConstants.java @@ -0,0 +1,108 @@ +package com.hindsight.king_of_castrop_rauxel.configuration; + +import com.hindsight.king_of_castrop_rauxel.encounter.Damage; +import com.hindsight.king_of_castrop_rauxel.world.Bounds; +import com.hindsight.king_of_castrop_rauxel.world.Range; +import lombok.NoArgsConstructor; + +import java.util.List; + +import static com.hindsight.king_of_castrop_rauxel.encounter.DungeonDetails.*; + +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public final class AppConstants { + + // GAME PROPERTIES + + /** + * The delay in milliseconds between each step. Currently used when displaying each action in an + * encounter. + */ + public static final long DELAY_IN_MS = 175; + + /** + * The speed modifier for the progress bar. The higher the value, the slower the progress. + * Examples: A modifier of 1 means that it takes 10 seconds to travel a distance of 100 km. A + * modifier of 0.5 means that it takes 5 seconds to travel a distance of 100 km. + */ + public static final float SPEED_MODIFIER = 0.1F; + + // WORLD PROPERTIES + public static final int WORLD_SIZE = 50; + public static final int WORLD_CENTER = WORLD_SIZE / 2; + public static final int RETENTION_ZONE = 2; + + // CHUNK PROPERTIES + public static final int CHUNK_SIZE = 500; + public static final int MIN_PLACEMENT_DISTANCE = 30; + public static final int MAX_GUARANTEED_NEIGHBOUR_DISTANCE = 130; + public static final int GENERATION_TRIGGER_ZONE = 100; + public static final Bounds DENSITY = new Bounds(5, 10); + + // LOCATION & POINT OF INTEREST PROPERTIES + // Settlements + public static final Bounds XS_INHABITANTS = new Bounds(1, 10); + public static final Bounds S_INHABITANTS = new Bounds(11, 100); + public static final Bounds M_INHABITANTS = new Bounds(101, 1000); + public static final Bounds L_INHABITANTS = new Bounds(1001, 10000); + public static final Bounds XL_INHABITANTS = new Bounds(10000, 250000); + public static final Bounds XS_AREA = new Bounds(1, 1); + public static final Bounds S_AREA = new Bounds(1, 2); + public static final Bounds M_AREA = new Bounds(1, 3); + public static final Bounds L_AREA = new Bounds(2, 8); + public static final Bounds XL_AREA = new Bounds(12, 30); + public static final Bounds XS_AMENITIES_ENTRANCE = new Bounds(0, 0); + public static final Bounds S_AMENITIES_ENTRANCE = new Bounds(0, 1); + public static final Bounds M_AMENITIES_ENTRANCE = new Bounds(1, 1); + public static final Bounds L_AMENITIES_ENTRANCE = new Bounds(2, 3); + public static final Bounds XL_AMENITIES_ENTRANCE = new Bounds(4, 5); + public static final Bounds AMENITIES_MAIN_SQUARE = new Bounds(1, 1); + public static final Bounds XS_AMENITIES_SHOP = new Bounds(0, 1); + public static final Bounds S_AMENITIES_SHOP = new Bounds(1, 3); + public static final Bounds M_AMENITIES_SHOP = new Bounds(3, 5); + public static final Bounds L_AMENITIES_SHOP = new Bounds(5, 9); + public static final Bounds XL_AMENITIES_SHOP = new Bounds(8, 12); + public static final Bounds XS_AMENITIES_QUEST_LOCATION = new Bounds(0, 2); + public static final Bounds S_AMENITIES_QUEST_LOCATION = new Bounds(2, 5); + public static final Bounds M_AMENITIES_QUEST_LOCATION = new Bounds(3, 6); + public static final Bounds L_AMENITIES_QUEST_LOCATION = new Bounds(7, 10); + public static final Bounds XL_AMENITIES_QUEST_LOCATION = new Bounds(9, 14); + public static final Bounds XS_AMENITIES_DUNGEON = new Bounds(0, 1); + public static final Bounds S_AMENITIES_DUNGEON = new Bounds(0, 1); + public static final Bounds M_AMENITIES_DUNGEON = new Bounds(1, 2); + public static final Bounds L_AMENITIES_DUNGEON = new Bounds(2, 3); + public static final Bounds XL_AMENITIES_DUNGEON = new Bounds(3, 4); + + // Dungeons + public static final Bounds ENCOUNTERS_PER_DUNGEON = new Bounds(1, 4); + public static final Bounds ENEMIES_PER_ENCOUNTER = new Bounds(1, 3); + public static final int DUNGEON_TIER_DIVIDER = 10; + public static final List DUNGEON_TYPES_T1 = + List.of(Type.GOBLIN, Type.IMP, Type.CYNOCEPHALY); + public static final List DUNGEON_TYPES_T2 = List.of(Type.SKELETON, Type.UNDEAD, Type.DEMON); + public static final List DUNGEON_TYPES_T3 = List.of(Type.ORC, Type.TROLL, Type.ONOCENTAUR); + public static final List DUNGEON_TYPES_T4 = + List.of(Type.CENTICORE, Type.POOKA, Type.MAPUCHE); + public static final List DUNGEON_TYPES_T5 = + List.of(Type.SPHINX, Type.MINOTAUR, Type.CHIMERA); + public static final List DUNGEON_TYPES_T6 = List.of(Type.CYCLOPS, Type.HYDRA, Type.PHOENIX); + + // ENEMIES PROPERTIES + // Basic Enemy + public static final Range T1_ENEMY_HP_XP_GOLD = new Range(10, 0.7F, 1.1F); + public static final Range T2_ENEMY_HP_XP_GOLD = new Range(8, 0.8F, 1.1F); + public static final Range T3_ENEMY_HP_XP_GOLD = new Range(6, 0.8F, 1.2F); + public static final Range T4_ENEMY_HP_XP_GOLD = new Range(4, 0.8F, 1.3F); + public static final Range T5_ENEMY_HP_XP_GOLD = new Range(2, 0.9F, 1.3F); + public static final Range T1_ENEMY_DAMAGE = new Range(1, 0, 2); + public static final Range T2_ENEMY_DAMAGE = new Range(1, 0.9F, 1.1F); + public static final Range T3_ENEMY_DAMAGE = new Range(1, 1, 1.2F); + public static final Range T4_ENEMY_DAMAGE = new Range(1, 1, 1.5F); + public static final Range T5_ENEMY_DAMAGE = new Range(1, 1.2F, 2); + + // PLAYER PROPERTIES + public static final int PLAYER_STARTING_GOLD = 100; + public static final int PLAYER_STARTING_MAX_HEALTH = 100; + public static final int PLAYER_EXPERIENCE_TO_LEVEL_UP = 100; + public static final Damage PLAYER_STARTING_DAMAGE = new Damage(1, 4); +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/AppProperties.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/AppProperties.java new file mode 100644 index 0000000..45b3f46 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/AppProperties.java @@ -0,0 +1,46 @@ +package com.hindsight.king_of_castrop_rauxel.configuration; + +import com.hindsight.king_of_castrop_rauxel.cli.CliComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Objects; + +@Slf4j +@Getter +@Setter +@ConfigurationProperties(prefix = "settings") +public class AppProperties { + + private Generation generation; + + private AutoUnload autoUnload; + + private Environment environment; + + @Getter private static Boolean isRunningAsJar; + + static { + determineRuntimeEnvironment(); + } + + public record Generation(AutoUnload autoUnload) {} + + public record AutoUnload(boolean world) {} + + public record Environment(boolean useConsoleUi, boolean clearConsole) {} + + private static void determineRuntimeEnvironment() { + var protocol = CliComponent.class.getResource(CliComponent.class.getSimpleName() + ".class"); + switch (Objects.requireNonNull(protocol).getProtocol()) { + case "jar" -> isRunningAsJar = true; + case "file" -> isRunningAsJar = false; + default -> log.error("Cannot determine runtime environment (JAR vs IDE)"); + } + if (isRunningAsJar != null) { + log.info("Running " + (Boolean.TRUE.equals(isRunningAsJar) ? "as JAR" : "inside IDE")); + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/EnvironmentResolver.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/EnvironmentResolver.java new file mode 100644 index 0000000..4c3b6da --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/configuration/EnvironmentResolver.java @@ -0,0 +1,23 @@ +package com.hindsight.king_of_castrop_rauxel.configuration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class EnvironmentResolver { + + @Value("${spring.profiles.active}") + private String activeProfile; + + public boolean isDev() { + return activeProfile.contains("dev"); + } + + public boolean isCli() { + return activeProfile.contains("cli"); + } + + public boolean isNotCli() { + return !activeProfile.contains("cli"); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/Damage.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/Damage.java new file mode 100644 index 0000000..eabf99c --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/Damage.java @@ -0,0 +1,32 @@ +package com.hindsight.king_of_castrop_rauxel.encounter; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.util.Random; + +@Getter +@Setter +@AllArgsConstructor +public class Damage { + + /** Inclusive lower bounds. */ + private int min; + + /** Inclusive upper bounds. */ + private int max; + + public static Damage of(int lower, int upper) { + return new Damage(lower, upper); + } + + public int actual(Random random) { + return random.nextInt(max - min + 1) + min; + } + + @Override + public String toString() { + return min + "-" + max; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/DungeonDetails.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/DungeonDetails.java new file mode 100644 index 0000000..5d17622 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/DungeonDetails.java @@ -0,0 +1,59 @@ +package com.hindsight.king_of_castrop_rauxel.encounter; + +import lombok.Builder; + +import java.util.Arrays; +import java.util.List; + +@Builder +public record DungeonDetails( + String id, + String name, + String description, + long seed, + int tier, + int level, + List> encounterDetails, + Type type) { + + public enum Type { + GOBLIN, + IMP, + CYNOCEPHALY, + SKELETON, + UNDEAD, + DEMON, + ORC, + TROLL, + ONOCENTAUR, + CENTICORE, + POOKA, + MAPUCHE, + SPHINX, + MINOTAUR, + CHIMERA, + CYCLOPS, + HYDRA, + PHOENIX, + DRAGON, + } + + @Override + public String toString() { + return "{id=" + + id + + ", name='" + + name + + ", description='" + + description + + ", tier=" + + tier + + ", level=" + + level + + ", encounterDetails=" + + Arrays.deepToString(encounterDetails.toArray()) + + ", type=" + + type + + '}'; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/EncounterBuilder.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/EncounterBuilder.java new file mode 100644 index 0000000..df75a06 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/EncounterBuilder.java @@ -0,0 +1,154 @@ +package com.hindsight.king_of_castrop_rauxel.encounter; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.*; + +import com.hindsight.king_of_castrop_rauxel.event.Loot; +import com.hindsight.king_of_castrop_rauxel.world.Range; +import java.util.*; +import java.util.stream.IntStream; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class EncounterBuilder { + + private static final Map> DUNGEON_TYPES_CONFIGS = + new HashMap<>(); + private static final Map ENEMY_CONFIGS = new HashMap<>(); + + public EncounterBuilder() { + configureDungeons(); + configureEnemies(); + log.debug(this.toString()); + } + + public static int getDungeonTier(int targetLevel) { + return (targetLevel / DUNGEON_TIER_DIVIDER) + 1; + } + + public static DungeonDetails.Type getDungeonType(Random random, int tier) { + var types = DUNGEON_TYPES_CONFIGS.get(tier); + return DungeonDetails.Type.valueOf(types.get(random.nextInt(types.size())).name()); + } + + public static List> getEncounterDetails( + Random random, int targetLevel, DungeonDetails.Type type) { + var encounterDetails = new ArrayList>(); + var encountersArray = getEncounters(random); + for (int encounter : encountersArray) { + var list = + IntStream.range(0, encounter) + .mapToObj(j -> getEnemyDetails(targetLevel, type, random)) + .toList(); + encounterDetails.add(list); + } + return encounterDetails; + } + + private static int[] getEncounters(Random random) { + var dLower = ENCOUNTERS_PER_DUNGEON.getLower(); + var dUpper = ENCOUNTERS_PER_DUNGEON.getUpper(); + var encounters = new int[random.nextInt(dUpper - dLower + 1) + dLower]; + var eLower = ENEMIES_PER_ENCOUNTER.getLower(); + var eUpper = ENEMIES_PER_ENCOUNTER.getUpper(); + Arrays.setAll(encounters, i -> random.nextInt(eUpper - eLower + 1) + eLower); + return encounters; + } + + private static EnemyDetails getEnemyDetails( + int targetLevel, DungeonDetails.Type type, Random random) { + var tier = getDungeonTier(targetLevel); + var config = ENEMY_CONFIGS.get(tier); + var damageBounds = config.getDamage().toBounds(targetLevel); + var damage = new Damage(damageBounds.getLower(), damageBounds.getUpper()); + var experience = config.getExperience().toRandomActual(random, targetLevel); + var gold = config.getGold().toRandomActual(random, targetLevel); + var loot = new Loot().gold(gold).experience(experience); + return EnemyDetails.builder() + .health(config.getHealth().toRandomActual(random, targetLevel)) + .damage(damage) + .loot(loot) + .level(targetLevel) + .type(type) + .build(); + } + + private void configureDungeons() { + DUNGEON_TYPES_CONFIGS.put(1, DUNGEON_TYPES_T1); + DUNGEON_TYPES_CONFIGS.put(2, DUNGEON_TYPES_T2); + DUNGEON_TYPES_CONFIGS.put(3, DUNGEON_TYPES_T3); + DUNGEON_TYPES_CONFIGS.put(4, DUNGEON_TYPES_T4); + DUNGEON_TYPES_CONFIGS.put(5, DUNGEON_TYPES_T5); + DUNGEON_TYPES_CONFIGS.put(6, DUNGEON_TYPES_T6); + } + + private void configureEnemies() { + ENEMY_CONFIGS.put( + 1, + EnemyConfig.builder() + .health(T1_ENEMY_HP_XP_GOLD) + .damage(T1_ENEMY_DAMAGE) + .experience(T1_ENEMY_HP_XP_GOLD) + .gold(T1_ENEMY_HP_XP_GOLD) + .build()); + ENEMY_CONFIGS.put( + 2, + EnemyConfig.builder() + .health(T2_ENEMY_HP_XP_GOLD) + .damage(T2_ENEMY_DAMAGE) + .experience(T2_ENEMY_HP_XP_GOLD) + .gold(T2_ENEMY_HP_XP_GOLD) + .build()); + ENEMY_CONFIGS.put( + 3, + EnemyConfig.builder() + .health(T3_ENEMY_HP_XP_GOLD) + .damage(T3_ENEMY_DAMAGE) + .experience(T3_ENEMY_HP_XP_GOLD) + .gold(T3_ENEMY_HP_XP_GOLD) + .build()); + ENEMY_CONFIGS.put( + 4, + EnemyConfig.builder() + .health(T4_ENEMY_HP_XP_GOLD) + .damage(T4_ENEMY_DAMAGE) + .experience(T4_ENEMY_HP_XP_GOLD) + .gold(T4_ENEMY_HP_XP_GOLD) + .build()); + ENEMY_CONFIGS.put( + 5, + EnemyConfig.builder() + .health(T5_ENEMY_HP_XP_GOLD) + .damage(T5_ENEMY_DAMAGE) + .experience(T5_ENEMY_HP_XP_GOLD) + .gold(T5_ENEMY_HP_XP_GOLD) + .build()); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("Available dungeon types by tier:%n".formatted()); + for (var entry : DUNGEON_TYPES_CONFIGS.entrySet()) { + sb.append("- Tier %s=%s%n".formatted(entry.getKey(), entry.getValue())); + } + return sb.toString(); + } + + @Getter + @Setter + @Builder + @ToString(includeFieldNames = false) + public static class EnemyConfig { + private Range health; + private Range damage; + private Range experience; + private Range gold; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/EncounterSequence.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/EncounterSequence.java new file mode 100644 index 0000000..b44f048 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/EncounterSequence.java @@ -0,0 +1,54 @@ +package com.hindsight.king_of_castrop_rauxel.encounter; + +import com.hindsight.king_of_castrop_rauxel.characters.BasicEnemy; +import com.hindsight.king_of_castrop_rauxel.characters.Combatant; +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.cli.combat.Encounter; +import com.hindsight.king_of_castrop_rauxel.event.Event; +import com.hindsight.king_of_castrop_rauxel.utils.Generators; +import java.util.ArrayList; +import java.util.List; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ToString(onlyExplicitlyIncluded = true) +public class EncounterSequence { + + @ToString.Include private final List encounters = new ArrayList<>(); + private int currentEncounter = 0; + private Event.State state = Event.State.AVAILABLE; + + public EncounterSequence(DungeonDetails dungeonDetails, Generators generators) { + var seed = dungeonDetails.seed(); + for (int i = 0; i < dungeonDetails.encounterDetails().size(); i++) { + var enemies = new ArrayList(); + var encounter = dungeonDetails.encounterDetails().get(i); + for (var enemyDetails : encounter) { + enemies.add(new BasicEnemy(enemyDetails, seed, generators.nameGenerator())); + } + encounters.add(new Encounter(null, enemies)); + } + } + + public void execute(Player player) { + execute(player, true); + } + + public void execute(Player player, boolean hasTheInitiative) { + state = Event.State.ACTIVE; + encounters.get(currentEncounter).execute(player, hasTheInitiative); + currentEncounter++; + if (currentEncounter >= encounters.size()) { + state = Event.State.COMPLETED; + } + } + + public boolean isInProgress() { + return state == Event.State.ACTIVE; + } + + public boolean isCompleted() { + return state == Event.State.COMPLETED; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/EnemyDetails.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/EnemyDetails.java new file mode 100644 index 0000000..5ecef95 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/encounter/EnemyDetails.java @@ -0,0 +1,24 @@ +package com.hindsight.king_of_castrop_rauxel.encounter; + +import com.hindsight.king_of_castrop_rauxel.event.Loot; +import lombok.Builder; + +@Builder +public record EnemyDetails( + int level, Loot loot, Damage damage, int health, DungeonDetails.Type type) { + + @Override + public String toString() { + return "EnemyDetails(level=" + + level + + ", loot=" + + loot + + ", damage=" + + damage + + ", health=" + + health + + ", type=" + + type + + ')'; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Dialogue.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Dialogue.java new file mode 100644 index 0000000..fd70847 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Dialogue.java @@ -0,0 +1,69 @@ +package com.hindsight.king_of_castrop_rauxel.event; + +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +public final class Dialogue { + + @Setter @Getter private List interactions = new ArrayList<>(); + @Setter @Getter private Event.State state; + private int current = 0; + + public Dialogue(List interactions) { + this.interactions.addAll(interactions); + this.state = Event.State.NONE; + } + + boolean hasCurrentInteraction() { + return current >= 0 && current < interactions.size(); + } + + Interaction getCurrentInteraction() { + return interactions.get(current); + } + + void setCurrentInteraction(int i) { + current = i; + } + + boolean isFirstInteraction() { + return current == 0; + } + + boolean hasNextInteraction() { + return current < interactions.size(); + } + + void progress() { + var next = hasCurrentInteraction() ? getCurrentInteraction().getNextInteraction() : null; + if (next == null) { + current++; + return; + } + if (next > getInteractions().size()) { + reset(); + return; + } + setCurrentInteraction(next); + } + + void rewindBy(int i) { + current -= i; + if (current < 0) { + current = 0; + } + } + + void reset() { + current = 0; + } + + @Override + public String toString() { + return "Dialogue(interactions=" + interactions + ", current=" + current + ")"; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/event/DialogueEvent.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/DialogueEvent.java new file mode 100644 index 0000000..e5a17c1 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/DialogueEvent.java @@ -0,0 +1,34 @@ +package com.hindsight.king_of_castrop_rauxel.event; + +import static com.hindsight.king_of_castrop_rauxel.event.Role.EVENT_GIVER; + +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +@Getter +public class DialogueEvent implements Event { + + private final EventDetails eventDetails; + @EqualsAndHashCode.Exclude private final List participants; + @EqualsAndHashCode.Exclude @Setter private Npc currentNpc; + @EqualsAndHashCode.Exclude @Setter private Dialogue currentDialogue; + @Setter private State eventState; + @Setter private boolean isRepeatable; + + public DialogueEvent( + EventDetails eventDetails, List participants, boolean isRepeatable) { + this.eventDetails = eventDetails; + this.participants = participants; + this.eventState = State.AVAILABLE; + this.isRepeatable = isRepeatable; + var eventGiver = + participants.stream() + .filter(p -> p.role().equals(EVENT_GIVER)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("NPC map must contain EVENT_GIVER role")); + setActive(eventGiver.npc()); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Event.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Event.java new file mode 100644 index 0000000..7a8a8e6 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Event.java @@ -0,0 +1,201 @@ +package com.hindsight.king_of_castrop_rauxel.event; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import java.util.List; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Events are linked to NPCs. Each NPC has at least one primary event. Events can be of different + * types, such as dialogues (single vs multistep dialogues), defeat events or reach events. + */ +public interface Event { + + EventDetails getEventDetails(); + + List getParticipants(); + + default List getParticipantNpcs() { + return getParticipants().stream().map(Participant::npc).toList(); + } + + default void setActive(Npc npc) { + var participant = + getParticipants().stream().filter(p -> p.npc().equals(npc)).findFirst().orElseThrow(); + setActive(participant); + } + + default void setActive(Participant participant) { + var dialogue = + participant.dialogues().stream() + .filter(d -> d.getState() == getEventState() || d.getState() == State.NONE) + .findFirst() + .orElseThrow(); + setCurrentDialogue(dialogue); + setCurrentNpc(participant.npc()); + } + + void setCurrentNpc(Npc npc); + + Npc getCurrentNpc(); + + /** + * Ideally, this method is only be used inside this interface. The intended way of changing the + * event state is by using the {@link #progressEvent(State)} or {@link #completeEvent(Player)} + * methods. + */ + void setEventState(State state); + + State getEventState(); + + boolean isRepeatable(); + + /** + * This method is only called by DialogueAction when changing the event state. Current interaction + * is set to -1 because it'll be incremented by 1 during the DialogLoop's post-processing which is + * before any interaction is displayed. + */ + default void progressEvent(State state) { + setEventState(state); + setCurrentDialogue(getDialogue(state)); + setCurrentInteraction(-1); + } + + /** + * This method is only called by DialogueAction when completing a quest. Current interaction is + * set to -1 because it'll be incremented by 1 during the DialogLoop's post-processing which is + * before any interaction is displayed. + */ + default void completeEvent(Player player) { + if (isRepeatable()) { + resetEvent(); + } else { + setEventState(Event.State.COMPLETED); + setCurrentDialogue(getDialogue(Event.State.COMPLETED)); + setCurrentInteraction(-1); + } + if (getEventDetails().hasRewards()) { + getEventDetails().getRewards().forEach(r -> r.give(player)); + } + player.setCurrentEvent(null); + player.setState(Player.State.AT_POI); + } + + default void resetEvent() { + getParticipants().forEach(p -> p.dialogues().forEach(Dialogue::reset)); + setCurrentDialogue(getDialogue(Event.State.AVAILABLE)); + setEventState(Event.State.AVAILABLE); + } + + default List getCurrentNpcDialogues() { + return getParticipants().stream() + .filter(p -> p.npc().equals(getCurrentNpc())) + .findFirst() + .orElseThrow() + .dialogues(); + } + + default Dialogue getDialogue(State state) { + return getCurrentNpcDialogues().stream() + .filter(d -> d.getState() == state) + .findFirst() + .orElseThrow(); + } + + Dialogue getCurrentDialogue(); + + void setCurrentDialogue(Dialogue dialogue); + + default boolean hasNextDialogue() { + if (getCurrentDialogue().getState() == Event.State.NONE) { + return false; + } + return getCurrentDialogue().getState().ordinal() < Event.State.values().length - 1; + } + + default void progressDialogue() { + getCurrentDialogue().progress(); + } + + default void rewindBy(int relativeStep) { + getCurrentDialogue().rewindBy(relativeStep); + } + + default void resetDialogue() { + getCurrentDialogue().reset(); + } + + /** + * Returns true if the current interaction is the first interaction of the dialogue, regardless of + * the state. + */ + default boolean isBeginningOfDialogue() { + return getCurrentDialogue().isFirstInteraction(); + } + + default boolean isDisplayable(Npc npc) { + var primaryEvent = npc.getPrimaryEvent(); + if (primaryEvent == null) { + return false; + } + if (primaryEvent.getEventDetails() == getEventDetails()) { + return true; + } + return getEventState() == Event.State.ACTIVE + || getEventState() == Event.State.READY + || getEventState() == Event.State.NONE; + } + + default boolean hasCurrentInteraction() { + return getCurrentDialogue().hasCurrentInteraction(); + } + + default void setCurrentInteraction(int i) { + if (i > getCurrentDialogue().getInteractions().size()) { + throw new IllegalArgumentException("The next interaction index is out of bounds: " + i); + } + getCurrentDialogue().setCurrentInteraction(i); + } + + default Interaction getCurrentInteraction() { + return getCurrentDialogue().getCurrentInteraction(); + } + + default List getCurrentActions() { + if (!hasCurrentInteraction()) { + return List.of(); + } + return getCurrentInteraction().getActions(); + } + + default boolean hasNextInteraction() { + return getCurrentDialogue().hasNextInteraction(); + } + + @Getter + @Slf4j + enum State { + NONE("None", 0), + AVAILABLE("Available", 1), + ACTIVE("Active", 2), + READY("Ready", 3), + COMPLETED("Completed", 4), + DECLINED("Declined", 5); + + private final String name; + private final int ordinal; + + State(String name, int ordinal) { + this.name = name; + this.ordinal = ordinal; + } + } + + enum Type { + DIALOGUE, + DEFEAT, + REACH + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/event/EventDetails.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/EventDetails.java new file mode 100644 index 0000000..cc77f01 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/EventDetails.java @@ -0,0 +1,38 @@ +package com.hindsight.king_of_castrop_rauxel.event; + +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.world.IdBuilder; +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@EqualsAndHashCode +public final class EventDetails { + + private String id; + private Event.Type eventType; + private List rewards; + private Npc eventGiver; + private String aboutGiver; // What the player will see when they see the event giver + private String aboutTarget; // What the player will see when they see an event target + + public EventDetails() { + this.id = IdBuilder.idFrom(this.getClass()); + this.eventType = Event.Type.DIALOGUE; + this.aboutGiver = ""; + this.aboutTarget = ""; + this.rewards = List.of(); + } + + public boolean hasRewards() { + return rewards != null && !rewards.isEmpty(); + } + + @Override + public String toString() { + return "EventDetails(id=" + id + ", eventType=" + eventType + ", rewards=" + rewards + ")"; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Interaction.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Interaction.java new file mode 100644 index 0000000..e0bc342 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Interaction.java @@ -0,0 +1,36 @@ +package com.hindsight.king_of_castrop_rauxel.event; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +public class Interaction { + + private List actions = new ArrayList<>(); + private Integer nextInteraction; + @Setter private String text; + + public Interaction(String text, List actions) { + this.text = text; + this.actions = actions; + this.nextInteraction = null; + } + + @Override + public String toString() { + return "Interaction(text=" + + text + + ", actions=" + + actions.size() + + ", nextInteraction=" + + nextInteraction + + ")"; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Loot.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Loot.java new file mode 100644 index 0000000..78dad9b --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Loot.java @@ -0,0 +1,73 @@ +package com.hindsight.king_of_castrop_rauxel.event; + +import static com.hindsight.king_of_castrop_rauxel.cli.CliComponent.*; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class Loot { + + private final List list = new ArrayList<>(); + + public Loot(List rewards) { + list.addAll(rewards); + } + + public void add(Reward reward) { + list.add(reward); + } + + public void add(Loot loot) { + list.addAll(loot.getList()); + } + + public Loot experience(int experience) { + list.add(new Reward(Reward.Type.EXPERIENCE, experience)); + return this; + } + + public Loot gold(int gold) { + list.add(new Reward(Reward.Type.GOLD, gold)); + return this; + } + + public void give(Player player) { + list.forEach(reward -> reward.give(player)); + } + + public int getGold() { + return list.stream() + .filter(reward -> reward.getType() == Reward.Type.GOLD) + .mapToInt(Reward::getValue) + .sum(); + } + + public int getExperience() { + return list.stream() + .filter(reward -> reward.getType() == Reward.Type.EXPERIENCE) + .mapToInt(Reward::getValue) + .sum(); + } + + @Override + public String toString() { + var rewards = new StringBuilder(); + int gold = getGold(); + if (gold > 0) { + rewards.append(FMT.YELLOW_BOLD).append(gold).append(FMT.RESET).append(" gold"); + } + if (rewards.length() > 1) { + rewards.append(" and "); + } + int exp = getExperience(); + if (exp > 0) { + rewards.append(FMT.BLUE_BOLD).append(exp).append(FMT.RESET).append(" XP"); + } + return rewards.toString(); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Participant.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Participant.java new file mode 100644 index 0000000..effe5de --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Participant.java @@ -0,0 +1,12 @@ +package com.hindsight.king_of_castrop_rauxel.event; + +import com.hindsight.king_of_castrop_rauxel.characters.Npc; + +import java.util.List; + +public record Participant(Npc npc, Role role, List dialogues) { + + public Participant(Npc npc, List dialogues) { + this(npc, Role.EVENT_GIVER, dialogues); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/event/ReachEvent.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/ReachEvent.java new file mode 100644 index 0000000..30d0877 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/ReachEvent.java @@ -0,0 +1,33 @@ +package com.hindsight.king_of_castrop_rauxel.event; + +import static com.hindsight.king_of_castrop_rauxel.event.Role.EVENT_GIVER; + +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +@Getter +public class ReachEvent implements Event { + + private final EventDetails eventDetails; + @EqualsAndHashCode.Exclude private final List participants; + @EqualsAndHashCode.Exclude @Setter private Npc currentNpc; + @EqualsAndHashCode.Exclude @Setter private Dialogue currentDialogue; + @Setter private State eventState; + @Setter private boolean isRepeatable; + + public ReachEvent(EventDetails eventDetails, List participants) { + this.eventDetails = eventDetails; + this.participants = participants; + this.eventState = State.AVAILABLE; + this.isRepeatable = false; + var eventGiver = + participants.stream() + .filter(p -> p.role().equals(EVENT_GIVER)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("NPC map must contain EVENT_GIVER role")); + setActive(eventGiver.npc()); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Reward.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Reward.java new file mode 100644 index 0000000..3aa9f61 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Reward.java @@ -0,0 +1,59 @@ +package com.hindsight.king_of_castrop_rauxel.event; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.cli.CliComponent; +import com.hindsight.king_of_castrop_rauxel.world.Randomisable; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Random; + +import static com.hindsight.king_of_castrop_rauxel.cli.CliComponent.*; + +@Getter +@Setter +@NoArgsConstructor +public class Reward implements Randomisable { + + private Type type; + private int value; + private int minValue; + private int maxValue; + private boolean isLoaded = false; + + /** Used to create a reward with a specific value e.g. Player.getReward(). */ + public Reward(Type type, int actualValue) { + this.type = type; + this.minValue = actualValue; + this.maxValue = actualValue; + this.value = actualValue; + this.isLoaded = true; + } + + @Override + public void load(Random random) { + this.value = random.nextInt(maxValue - minValue + 1) + minValue; + } + + public void give(Player player) { + if (!isLoaded) { + load(new Random()); + } + switch (type) { + case GOLD -> player.changeGoldBy(value); + case EXPERIENCE -> player.addExperience(value); + } + } + + public enum Type { + GOLD, + EXPERIENCE + } + + @Override + public String toString() { + var colour = CliComponent.toColour(type).toString(); + return colour + value + FMT.RESET + " " + type.toString().toLowerCase(); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Role.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Role.java new file mode 100644 index 0000000..1ba3123 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/event/Role.java @@ -0,0 +1,6 @@ +package com.hindsight.king_of_castrop_rauxel.event; + +public enum Role { + EVENT_GIVER, + EVENT_TARGET +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/game/GameHandler.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/game/GameHandler.java new file mode 100644 index 0000000..6aadc60 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/game/GameHandler.java @@ -0,0 +1,63 @@ +package com.hindsight.king_of_castrop_rauxel.game; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.graphs.Graph; +import com.hindsight.king_of_castrop_rauxel.location.AbstractLocation; +import com.hindsight.king_of_castrop_rauxel.world.ChunkBuilder; +import com.hindsight.king_of_castrop_rauxel.world.Coordinates; +import com.hindsight.king_of_castrop_rauxel.world.World; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor(onConstructor = @__(@Autowired)) +public class GameHandler { + + private final World world; + private final Graph map; + + public void updateWorld(Player player) { + updateWorldCoords(player); + generateNextChunk(player); + } + + private void updateWorldCoords(Player player) { + var worldCoords = world.getCurrentChunk().getCoordinates().getWorld(); + if (!player.getCoordinates().equalTo(worldCoords, Coordinates.CoordType.WORLD)) { + log.info(String.format("Player is leaving: %s%n", world.getCurrentChunk().getSummary())); + world.setCurrentChunk(player.getCoordinates().getWorld()); + log.info(String.format("Player is entering: %s%n", world.getCurrentChunk().getSummary())); + } + } + + private void generateNextChunk(Player player) { + var chunkCoords = player.getCurrentLocation().getCoordinates().getChunk(); + if (ChunkBuilder.isInsideTriggerZone(chunkCoords)) { + var whereNext = ChunkBuilder.nextChunkPosition(chunkCoords); + log.info("Player is inside {}ern trigger zone", whereNext.getName().toLowerCase()); + if (world.hasChunk(whereNext)) { + log.info("{} chunk already exists - skipping generation", whereNext.getName()); + return; + } + world.generateChunk(whereNext, map); + } + } + + public void updateCurrentEventDialogue(Player player) { + if (!player.hasCurrentEvent()) { + return; + } + var currentEvent = player.getCurrentEvent(); + currentEvent.progressDialogue(); + if (player.getState() != Player.State.IN_DIALOGUE) { + currentEvent.resetDialogue(); + return; + } + if (!currentEvent.hasNextInteraction()) { + player.setState(player.getPreviousState()); + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/graphs/Edge.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/graphs/Edge.java new file mode 100644 index 0000000..1db98cc --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/graphs/Edge.java @@ -0,0 +1,5 @@ +package com.hindsight.king_of_castrop_rauxel.graphs; + +import com.hindsight.king_of_castrop_rauxel.location.Location; + +public record Edge(Vertex start, Vertex end, Integer weight) {} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/graphs/Graph.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/graphs/Graph.java new file mode 100644 index 0000000..1ef3559 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/graphs/Graph.java @@ -0,0 +1,93 @@ +package com.hindsight.king_of_castrop_rauxel.graphs; + +import com.hindsight.king_of_castrop_rauxel.location.AbstractLocation; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import com.hindsight.king_of_castrop_rauxel.world.Coordinates; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; + +@Slf4j +@Getter +public class Graph { + + private final List> vertices = new ArrayList<>(); + private final boolean isWeighted; + + public Graph(boolean isWeighted) { + this.isWeighted = isWeighted; + } + + public Vertex addVertex(T location) { + Vertex newVertex = new Vertex<>(location); + this.vertices.add(newVertex); + return newVertex; + } + + public void addEdge(Vertex vertex1, Vertex vertex2, Integer weight) { + if (!this.isWeighted) { + weight = null; + } + vertex2.addEdge(vertex1, weight); + vertex1.addEdge(vertex2, weight); + } + + public void removeEdge(Vertex vertex1, Vertex vertex2) { + vertex1.removeEdge(vertex2); + vertex2.removeEdge(vertex1); + } + + public void removeVertex(Vertex vertex) { + this.vertices.remove(vertex); + } + + public Vertex getVertexByValue(T location) { + for (Vertex vertex : this.vertices) { + if (vertex.getLocation().equals(location)) { + return vertex; + } + } + return null; + } + + public Vertex getVertexByValue(Pair anyCoords, Coordinates.CoordType type) { + var rX = (int) anyCoords.getFirst(); + var rY = (int) anyCoords.getSecond(); + for (Vertex vertex : this.vertices) { + var vCoords = + switch (type) { + case WORLD -> vertex.getLocation().getCoordinates().getWorld(); + case GLOBAL -> vertex.getLocation().getCoordinates().getGlobal(); + case CHUNK -> vertex.getLocation().getCoordinates().getChunk(); + }; + var vX = (int) vCoords.getFirst(); + var vY = (int) vCoords.getSecond(); + if (vX == rX && vY == rY) { + return vertex; + } + } + return null; + } + + public void log() { + log.info("Graph: "); + for (Vertex vertex : this.vertices) { + vertex.log(isWeighted); + } + } + + public static void traverseGraphDepthFirst( + Vertex currentVertex, Set> visitedVertices, Set> unvisitedVertices) { + if (visitedVertices.contains(currentVertex)) { + return; + } + visitedVertices.add(currentVertex); + unvisitedVertices.remove(currentVertex); + for (var edge : currentVertex.getEdges()) { + traverseGraphDepthFirst(edge.end(), visitedVertices, unvisitedVertices); + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/graphs/Vertex.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/graphs/Vertex.java new file mode 100644 index 0000000..08b0455 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/graphs/Vertex.java @@ -0,0 +1,75 @@ +package com.hindsight.king_of_castrop_rauxel.graphs; + +import com.hindsight.king_of_castrop_rauxel.location.Location; +import java.util.LinkedHashSet; +import java.util.Set; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class Vertex { + + @EqualsAndHashCode.Include private final String id; + private final Set> edges; + private final T location; + + public Vertex(T location) { + this.id = + "VER~" + + location.getName().substring(0, 3).toUpperCase() + + location.getCoordinates().getGlobal().getFirst() + + location.getCoordinates().getGlobal().getSecond(); + this.location = location; + this.edges = new LinkedHashSet<>(); + } + + public void addEdge(Vertex endVertex, Integer weight) { + this.edges.add(new Edge<>(this, endVertex, weight)); + } + + public void removeEdge(Vertex endVertex) { + this.edges.removeIf(edge -> edge.end().equals(endVertex)); + } + + public void log(boolean showWeight) { + if (edges.isEmpty()) { + log.info( + "- " + location.getName() + " " + location.getCoordinates().globalToString() + " -->"); + return; + } + StringBuilder message = new StringBuilder(); + boolean first = true; + for (Edge edge : edges) { + if (first) { + message + .append("- ") + .append(edge.start().location.getName()) + .append(" ") + .append(edge.start().location.getCoordinates().globalToString()) + .append(" --> "); + first = false; + } + message.append(edge.end().location.getName()); + if (showWeight) { + message.append(" (").append(edge.weight()).append(")"); + } + message.append(", "); + } + message.setLength(message.length() - 2); + log.info(message.toString()); + } + + @Override + public String toString() { + return "Vertex(id=" + + id + + ", edges=" + + edges.stream().map(e -> e.end().location.getName()).toList() + + ", location=" + + location + + ')'; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/items/Buyable.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/items/Buyable.java new file mode 100644 index 0000000..697ceee --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/items/Buyable.java @@ -0,0 +1,14 @@ +package com.hindsight.king_of_castrop_rauxel.items; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; + +public interface Buyable { + + String getName(); + + String getDescription(); + + int getBasePrice(); + + boolean isBoughtBy(Player player); +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/items/Consumable.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/items/Consumable.java new file mode 100644 index 0000000..2444353 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/items/Consumable.java @@ -0,0 +1,55 @@ +package com.hindsight.king_of_castrop_rauxel.items; + +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.cli.CliComponent; +import com.hindsight.king_of_castrop_rauxel.location.Shop; +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@SuppressWarnings("JpaDataSourceORMInspection") +@Entity +@Table(name = "ITEMS_CONSUMABLES") +@NoArgsConstructor +@ToString +@EqualsAndHashCode +public class Consumable implements Buyable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Getter private String name; + private int tier; + + @Enumerated(EnumType.STRING) + private Shop.Type sellerType; + + @Getter private int basePrice; + private int effectHealth; + private int effectMaxHealth; + + @Override + public String getDescription() { + var stringBuilder = new StringBuilder(); + var restoresHealth = "restores " + CliComponent.health(effectHealth) + " HP, "; + stringBuilder.append(effectHealth > 0 ? restoresHealth : ""); + var increasesMaxHealth = "increases max HP by " + CliComponent.health(effectMaxHealth) + ", "; + stringBuilder.append(effectMaxHealth > 0 ? increasesMaxHealth : ""); + stringBuilder.setLength(stringBuilder.length() - 2); + return stringBuilder.toString(); + } + + @Override + public boolean isBoughtBy(Player player) { + if (player.getGold() < basePrice) { + return false; + } + player.changeGoldBy(-basePrice); + player.changeHealthBy(effectHealth); + player.changeMaxHealthBy(effectMaxHealth); + return true; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/items/ConsumableService.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/items/ConsumableService.java new file mode 100644 index 0000000..e36b56a --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/items/ConsumableService.java @@ -0,0 +1,26 @@ +package com.hindsight.king_of_castrop_rauxel.items; + +import com.hindsight.king_of_castrop_rauxel.location.Shop; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class ConsumableService { + + private final ConsumablesRepository consumablesRepository; + + public ConsumableService(ConsumablesRepository consumablesRepository) { + this.consumablesRepository = consumablesRepository; + log.info("Loaded {} consumables from database", getAllConsumables().size()); + } + + public List getAllConsumables() { + return (List) consumablesRepository.findAll(); + } + + public List getConsumablesByType(Shop.Type type) { + return consumablesRepository.findBySellerType(type); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/items/ConsumablesRepository.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/items/ConsumablesRepository.java new file mode 100644 index 0000000..7985808 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/items/ConsumablesRepository.java @@ -0,0 +1,12 @@ +package com.hindsight.king_of_castrop_rauxel.items; + +import com.hindsight.king_of_castrop_rauxel.location.Shop; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ConsumablesRepository extends CrudRepository { + List findBySellerType(Shop.Type type); +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/AbstractAmenity.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/AbstractAmenity.java index a0086b1..cbb7eab 100644 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/AbstractAmenity.java +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/AbstractAmenity.java @@ -1,23 +1,69 @@ package com.hindsight.king_of_castrop_rauxel.location; -import com.hindsight.king_of_castrop_rauxel.settings.LocationComponent; -import lombok.Getter; -import lombok.ToString; +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.action.EventAction; +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.event.Event; +import com.hindsight.king_of_castrop_rauxel.world.Generatable; +import com.hindsight.king_of_castrop_rauxel.world.IdBuilder; +import com.hindsight.king_of_castrop_rauxel.world.SeedBuilder; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import lombok.*; import lombok.extern.slf4j.Slf4j; @Slf4j -@ToString(callSuper = true) @Getter -public abstract class AbstractAmenity extends AbstractLocation { +@ToString(includeFieldNames = false, onlyExplicitlyIncluded = true) +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public abstract class AbstractAmenity implements PointOfInterest, Generatable { - protected LocationComponent.AmenityType type; + @Getter(AccessLevel.NONE) + protected final List availableActions = new ArrayList<>(); - protected AbstractAmenity() { - super(); + @EqualsAndHashCode.Include protected final String id; + @EqualsAndHashCode.Include protected final long seed; + @ToString.Include protected final Type type; + protected final Location parent; + protected final Npc npc; + @ToString.Include @Setter protected String name; + @Setter protected String description; + @Setter private boolean isLoaded; + protected Random random; + + protected AbstractAmenity(Type type, Npc npc, Location parent) { + this.id = IdBuilder.idFrom(this.getClass()); + this.seed = SeedBuilder.seedFrom(parent.getCoordinates().getGlobal()); + this.random = new Random(seed); + this.type = type; + this.parent = parent; + this.npc = npc; + if (npc != null) { + npc.setHome(this); + } } @Override public String getSummary() { - return "%s [ Type: %s ]".formatted(name, type); + return "%s [ Type: %s | Located in %s ]".formatted(name, type, parent.getName()); + } + + @Override + public void addAvailableAction(Event event) { + if (type == Type.QUEST_LOCATION || type == Type.SHOP) { + addEventAction(event); + } + } + + protected void addEventAction(Event event) { + var action = + EventAction.builder() + .name("Speak with %s".formatted(npc.getName())) + .index(availableActions.size() + 1) + .event(event) + .npc(npc) + .build(); + availableActions.add(action); } } diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/AbstractLocation.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/AbstractLocation.java index 5c471a3..7ee776f 100644 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/AbstractLocation.java +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/AbstractLocation.java @@ -1,25 +1,43 @@ package com.hindsight.king_of_castrop_rauxel.location; -import com.hindsight.king_of_castrop_rauxel.action.PlayerAction; -import com.hindsight.king_of_castrop_rauxel.utils.Visitor; +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.characters.Visitor; +import com.hindsight.king_of_castrop_rauxel.world.Coordinates; +import com.hindsight.king_of_castrop_rauxel.world.IdBuilder; +import com.hindsight.king_of_castrop_rauxel.world.SeedBuilder; import java.util.*; + +import com.hindsight.king_of_castrop_rauxel.world.WorldHandler; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; import lombok.ToString; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; @Slf4j -@ToString(exclude = {"description", "visitors", "availableActions"}) +@ToString( + of = {"name", "coordinates", "isLoaded"}, + includeFieldNames = false) +@EqualsAndHashCode(onlyExplicitlyIncluded = true) public abstract class AbstractLocation implements Location { - @Getter protected final String id; + @EqualsAndHashCode.Include @Getter protected final String id; + @EqualsAndHashCode.Include @Getter protected final long seed; @Getter @Setter protected String name; @Getter @Setter protected String description; - @Getter protected List availableActions = new ArrayList<>(); + protected List availableActions = new ArrayList<>(); protected Set visitors = new HashSet<>(); + protected Random random; + @EqualsAndHashCode.Include @Getter protected final Coordinates coordinates; + @Getter @Setter private boolean isLoaded; - protected AbstractLocation() { - this.id = UUID.randomUUID().toString(); + protected AbstractLocation( + Pair worldCoords, Pair chunkCoords) { + this.coordinates = new Coordinates(worldCoords, chunkCoords); + this.seed = SeedBuilder.seedFrom(coordinates.getGlobal()); + this.random = new Random(seed); + this.id = IdBuilder.idFrom(this.getClass(), coordinates); } @Override @@ -36,4 +54,35 @@ public boolean hasVisited(Visitor visitor) { public void addVisitor(Visitor visitor) { visitors.add(visitor); } + + @Override + public WorldHandler.CardinalDirection getCardinalDirection(Pair other) { + int dx = other.getFirst() - getCoordinates().cX(); + int dy = other.getSecond() - getCoordinates().cY(); + + if (dx == 0) { + if (dy < 0) { + return WorldHandler.CardinalDirection.NORTH; + } else if (dy > 0) { + return WorldHandler.CardinalDirection.SOUTH; + } + } else if (dy == 0) { + if (dx < 0) { + return WorldHandler.CardinalDirection.WEST; + } else { + return WorldHandler.CardinalDirection.EAST; + } + } else { + if (dx < 0 && dy < 0) { + return WorldHandler.CardinalDirection.NORTH_WEST; + } else if (dx < 0) { + return WorldHandler.CardinalDirection.SOUTH_WEST; + } else if (dy < 0) { + return WorldHandler.CardinalDirection.NORTH_EAST; + } else { + return WorldHandler.CardinalDirection.SOUTH_EAST; + } + } + return WorldHandler.CardinalDirection.THIS; + } } diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/AbstractSettlement.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/AbstractSettlement.java index d558119..d84eeeb 100644 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/AbstractSettlement.java +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/AbstractSettlement.java @@ -1,53 +1,115 @@ package com.hindsight.king_of_castrop_rauxel.location; -import static com.hindsight.king_of_castrop_rauxel.settings.LocationComponent.*; +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.action.PoiAction; +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.characters.Player; +import com.hindsight.king_of_castrop_rauxel.utils.DataServices; +import com.hindsight.king_of_castrop_rauxel.utils.Generators; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; -import com.hindsight.king_of_castrop_rauxel.player.Player; -import java.util.*; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; @Slf4j -@EqualsAndHashCode(callSuper = true) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) public abstract class AbstractSettlement extends AbstractLocation { - protected final Random random = new Random(); - - protected Size size; + @Getter protected final Generators generators; + @Getter protected final DataServices dataServices; + @Getter protected Size size; protected Player loyalTo; - protected int inhabitants; - protected List neighbours = new ArrayList<>(); - protected List amenities = new ArrayList<>(); + protected int area; + protected List pointsOfInterests = new ArrayList<>(); + @Getter protected List inhabitants = new ArrayList<>(); + @EqualsAndHashCode.Include @Getter protected int inhabitantCount; + @Getter protected Set neighbours = new HashSet<>(); + + protected AbstractSettlement( + Pair worldCoords, + Pair chunkCoords, + Generators generators, + DataServices dataServices) { + super(worldCoords, chunkCoords); + this.generators = generators; + this.dataServices = dataServices; + generators.initialiseAll(random); + } + + public void addNeighbour(Location neighbour) { + neighbours.add(neighbour); + } + + public PointOfInterest getDefaultPoi() { + return pointsOfInterests.stream() + .filter(a -> a.getType() == PointOfInterest.Type.MAIN_SQUARE) + .findFirst() + .orElse(null); + } + + @Override + public List getAvailableActions() { + return availableActions.stream().filter(hasActionsOrIsMainSquare()).toList(); + } + + private static Predicate hasActionsOrIsMainSquare() { + return a -> { + if (!(a instanceof PoiAction pa)) return false; + var isMainSquare = pa.getPoi().getType() == PointOfInterest.Type.MAIN_SQUARE; + return !pa.getPoi().getAvailableActions().isEmpty() || isMainSquare; + }; + } + + @Override + public List getPointsOfInterest() { + return pointsOfInterests; + } @Override public String toString() { - return "AbstractSettlement(super=" - + super.toString() - + "), size=" + return super.toString() + + ", size=" + size + ", loyalTo=" + loyalTo + ", inhabitants=" - + inhabitants + + inhabitants.size() + + ", inhabitantCount=" + + inhabitantCount + ", neighbours=" + neighbours.size() + ", amenities=" - + amenities.size(); + + pointsOfInterests.size(); + } + + @Override + public String getBriefSummary() { + return "%s [ Size: %s | %s | Neighbours: %s | Generated: %s ]" + .formatted(name, size, coordinates.toString(), neighbours.size(), isLoaded()); } @Override - public String getSummary() { - return "%s [ Size: %s | Inhabitants: %d | Amenities: %s | Neighbours: %s | %s ]" + public String getFullSummary() { + return "%s [ Size: %s | %d inhabitants | Population density: %s | %s points of interest | Coordinates: %s | Connected to %s location(s) | Stance: %s ]" .formatted( name, - size, - inhabitants, - amenities.size(), + size.getName(), + inhabitantCount, + getPopulationDensity(), + pointsOfInterests.size(), + coordinates.globalToString(), neighbours.size(), loyalTo == null ? "Neutral" : "Loyal to " + loyalTo.getName()); } - protected AbstractSettlement() { - super(); + private String getPopulationDensity() { + return String.format("%.1f", (float) inhabitants.size() / area) + "/sq. km"; } } diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Amenity.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Amenity.java index 6533982..2020309 100644 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Amenity.java +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Amenity.java @@ -1,46 +1,70 @@ package com.hindsight.king_of_castrop_rauxel.location; -import com.hindsight.king_of_castrop_rauxel.utils.BasicStringGenerator; +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.action.EventAction; +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.cli.CliComponent; +import com.hindsight.king_of_castrop_rauxel.event.Event; +import java.util.ArrayList; +import java.util.List; import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.extern.slf4j.Slf4j; -import static com.hindsight.king_of_castrop_rauxel.settings.LocationComponent.*; - @Slf4j -@EqualsAndHashCode(callSuper = true) -@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) +@ToString(callSuper = true, includeFieldNames = false) public class Amenity extends AbstractAmenity { - public Amenity(String parentName) { - generate(parentName); - logResult(); - } + public static final String ABOUT = " about "; - public Amenity(AmenityType type, String parentName) { - this.type = type; - generate(type, parentName); + public Amenity(Type type, Npc npc, Location parent) { + super(type, npc, parent); + load(); logResult(); } @Override - public void generate() { - generate(null); + public void load() { + LocationBuilder.throwIfRepeatedRequest(this, true); + this.name = + parent + .getGenerators() + .nameGenerator() + .locationNameFrom(this.getClass(), this, parent.getSize(), parent.getName(), npc); + setLoaded(true); } @Override - public void generate(String parentName) { - this.name = - parentName == null - ? BasicStringGenerator.generate(this.getClass()) - : BasicStringGenerator.generate(parentName, this.getClass()); + public List getAvailableActions() { + var processedActions = new ArrayList(); + for (var action : availableActions) { + if (!(action instanceof EventAction eventAction)) { + processedActions.add(action); + continue; + } + if (eventAction.getEvent().isDisplayable(npc)) { + var processedAction = EventAction.from(eventAction); + processEventActionName(processedAction); + processedActions.add(processedAction); + } + } + return processedActions; } - public void generate(AmenityType type, String parentName) { - this.name = - parentName == null - ? BasicStringGenerator.generate(type, this.getClass()) - : BasicStringGenerator.generate(type, parentName, this.getClass()); + private void processEventActionName(EventAction action) { + var details = action.getEvent().getEventDetails(); + if (details.getEventType() == Event.Type.DIALOGUE) { + action.setName(action.getName() + CliComponent.label(CliComponent.Type.DIALOGUE)); + } else { + var isEventGiver = details.getEventGiver().equals(npc); + var aboutText = isEventGiver ? details.getAboutGiver() : details.getAboutTarget(); + action.setName(getProcessedName(action, aboutText)); + } + } + + private static String getProcessedName(EventAction action, String text) { + return action.getName() + ABOUT + text + CliComponent.label(CliComponent.Type.QUEST); } @Override diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Dungeon.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Dungeon.java new file mode 100644 index 0000000..817a3fb --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Dungeon.java @@ -0,0 +1,101 @@ +package com.hindsight.king_of_castrop_rauxel.location; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.action.CombatAction; +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.cli.CliComponent; +import com.hindsight.king_of_castrop_rauxel.encounter.EncounterSequence; + +import java.util.ArrayList; +import java.util.List; + +import com.hindsight.king_of_castrop_rauxel.encounter.DungeonDetails; +import com.hindsight.king_of_castrop_rauxel.encounter.EncounterBuilder; +import com.hindsight.king_of_castrop_rauxel.utils.Generators; +import com.hindsight.king_of_castrop_rauxel.world.SeedBuilder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +@Getter +@Slf4j +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) +@ToString(callSuper = true, onlyExplicitlyIncluded = true, includeFieldNames = false) +public class Dungeon extends AbstractAmenity { + + private final Generators generators; + private EncounterSequence sequence; + private DungeonDetails dungeonDetails; + + public Dungeon(PointOfInterest.Type type, Npc npc, Location parent) { + super(type, npc, parent); + this.generators = parent.getGenerators(); + load(); + logResult(); + } + + @Override + public void load() { + this.dungeonDetails = createDungeonDetails(); + this.name = dungeonDetails.name(); + this.description = dungeonDetails.description(); + this.sequence = new EncounterSequence(dungeonDetails, parent.getGenerators()); + setLoaded(true); + } + + private DungeonDetails createDungeonDetails() { + var targetLevel = generators.terrainGenerator().getTargetLevel(parent.getCoordinates()); + var tier = EncounterBuilder.getDungeonTier(targetLevel); + var type = EncounterBuilder.getDungeonType(random, tier); + var encounterDetails = EncounterBuilder.getEncounterDetails(random, targetLevel, type); + var seed = SeedBuilder.seedFrom(parent.getCoordinates().getGlobal()); + var dungeonName = generators.nameGenerator().dungeonNameFrom(this.getClass(), type); + var dungeonDescription = + generators.nameGenerator().dungeonDescriptionFrom(parent.getClass(), type); + return DungeonDetails.builder() + .id(id) + .name(dungeonName) + .description(dungeonDescription) + .tier(tier) + .level(targetLevel) + .encounterDetails(encounterDetails) + .type(type) + .seed(seed) + .build(); + } + + @Override + public List getAvailableActions() { + var processedActions = new ArrayList<>(availableActions); + addEnterDungeonAction(processedActions); + return processedActions; + } + + private void addEnterDungeonAction(ArrayList processedActions) { + if (sequence.isCompleted()) { + return; + } + var labelText = "Combat, level " + dungeonDetails.level() + "+"; + var label = CliComponent.label(labelText, CliComponent.FMT.RED); + var actionName = "Storm the " + name + (sequence.isInProgress() ? " again" : "") + label; + processedActions.add( + CombatAction.builder() + .name(actionName) + .index(availableActions.size() + 1) + .sequence(sequence) + .build()); + } + + @Override + public String getDescription() { + var done = ", devoid of any life. You have slain all creatures here already. "; + var toDo = ", rumored to be filled with treasure. "; + return "%s%s".formatted(dungeonDetails.description(), sequence.isCompleted() ? done : toDo); + } + + @Override + public void logResult() { + log.info("Generated: {}", this); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Location.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Location.java index e0af602..38a6f59 100644 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Location.java +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Location.java @@ -1,19 +1,47 @@ package com.hindsight.king_of_castrop_rauxel.location; -import com.hindsight.king_of_castrop_rauxel.action.PlayerAction; -import com.hindsight.king_of_castrop_rauxel.utils.Generatable; -import com.hindsight.king_of_castrop_rauxel.utils.Visitable; +import static com.hindsight.king_of_castrop_rauxel.world.WorldHandler.*; +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.utils.DataServices; +import com.hindsight.king_of_castrop_rauxel.utils.Generators; +import com.hindsight.king_of_castrop_rauxel.world.Coordinates; +import com.hindsight.king_of_castrop_rauxel.world.Generatable; import java.util.List; +import java.util.Set; +import org.springframework.data.util.Pair; public interface Location extends Visitable, Generatable { + String getName(); - String getId(); + Size getSize(); String getDescription(); - List getAvailableActions(); + List getAvailableActions(); + + List getPointsOfInterest(); + + PointOfInterest getDefaultPoi(); + + Set getNeighbours(); + + void addNeighbour(Location neighbour); + + Coordinates getCoordinates(); + + CardinalDirection getCardinalDirection(Pair otherCoordinates); + + Generators getGenerators(); + + DataServices getDataServices(); + + String getFullSummary(); // TODO: Replace with objects so that it can be used via API + + String getBriefSummary(); - String getSummary(); // TODO: Replace with objects so that it can be used via API + default int distanceTo(Location end) { + return getCoordinates().distanceTo(end.getCoordinates()); + } } diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/LocationBuilder.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/LocationBuilder.java new file mode 100644 index 0000000..092d42e --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/LocationBuilder.java @@ -0,0 +1,152 @@ +package com.hindsight.king_of_castrop_rauxel.location; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.*; +import static com.hindsight.king_of_castrop_rauxel.location.PointOfInterest.Type; + +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.world.Bounds; +import com.hindsight.king_of_castrop_rauxel.world.Generatable; + +import java.util.*; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class LocationBuilder { + + private static final Map SETTLEMENT_CONFIGS = new EnumMap<>(Size.class); + + public LocationBuilder() { + configureSettlements(); + log.debug(this.toString()); + } + + public static SettlementConfig getSettlementConfig(Size size) { + return SETTLEMENT_CONFIGS.get(size); + } + + private void configureSettlements() { + SettlementConfig xs = new SettlementConfig(); + SettlementConfig s = new SettlementConfig(); + SettlementConfig m = new SettlementConfig(); + SettlementConfig l = new SettlementConfig(); + SettlementConfig xl = new SettlementConfig(); + + xs.setInhabitants(XS_INHABITANTS); + s.setInhabitants(S_INHABITANTS); + m.setInhabitants(M_INHABITANTS); + l.setInhabitants(L_INHABITANTS); + xl.setInhabitants(XL_INHABITANTS); + + xs.area = XS_AREA; + s.area = S_AREA; + m.area = M_AREA; + l.area = L_AREA; + xl.area = XL_AREA; + + xs.amenities = new EnumMap<>(Type.class); + s.amenities = new EnumMap<>(Type.class); + m.amenities = new EnumMap<>(Type.class); + l.amenities = new EnumMap<>(Type.class); + xl.amenities = new EnumMap<>(Type.class); + + xs.amenities.put(Type.ENTRANCE, XS_AMENITIES_ENTRANCE); + s.amenities.put(Type.ENTRANCE, S_AMENITIES_ENTRANCE); + m.amenities.put(Type.ENTRANCE, M_AMENITIES_ENTRANCE); + l.amenities.put(Type.ENTRANCE, L_AMENITIES_ENTRANCE); + xl.amenities.put(Type.ENTRANCE, XL_AMENITIES_ENTRANCE); + + xs.amenities.put(Type.MAIN_SQUARE, AMENITIES_MAIN_SQUARE); + s.amenities.put(Type.MAIN_SQUARE, AMENITIES_MAIN_SQUARE); + m.amenities.put(Type.MAIN_SQUARE, AMENITIES_MAIN_SQUARE); + l.amenities.put(Type.MAIN_SQUARE, AMENITIES_MAIN_SQUARE); + xl.amenities.put(Type.MAIN_SQUARE, AMENITIES_MAIN_SQUARE); + + xs.amenities.put(Type.SHOP, XS_AMENITIES_SHOP); + s.amenities.put(Type.SHOP, S_AMENITIES_SHOP); + m.amenities.put(Type.SHOP, M_AMENITIES_SHOP); + l.amenities.put(Type.SHOP, L_AMENITIES_SHOP); + xl.amenities.put(Type.SHOP, XL_AMENITIES_SHOP); + + xs.amenities.put(Type.QUEST_LOCATION, XS_AMENITIES_QUEST_LOCATION); + s.amenities.put(Type.QUEST_LOCATION, S_AMENITIES_QUEST_LOCATION); + m.amenities.put(Type.QUEST_LOCATION, M_AMENITIES_QUEST_LOCATION); + l.amenities.put(Type.QUEST_LOCATION, L_AMENITIES_QUEST_LOCATION); + xl.amenities.put(Type.QUEST_LOCATION, XL_AMENITIES_QUEST_LOCATION); + + xs.amenities.put(Type.DUNGEON, XS_AMENITIES_DUNGEON); + s.amenities.put(Type.DUNGEON, S_AMENITIES_DUNGEON); + m.amenities.put(Type.DUNGEON, M_AMENITIES_DUNGEON); + l.amenities.put(Type.DUNGEON, L_AMENITIES_DUNGEON); + xl.amenities.put(Type.DUNGEON, XL_AMENITIES_DUNGEON); + + SETTLEMENT_CONFIGS.put(Size.XS, xs); + SETTLEMENT_CONFIGS.put(Size.S, s); + SETTLEMENT_CONFIGS.put(Size.M, m); + SETTLEMENT_CONFIGS.put(Size.L, l); + SETTLEMENT_CONFIGS.put(Size.XL, xl); + } + + /** + * Returns a random Size enum. Must be provided with a Random in order to ensure reproducibility. + */ + public static Size randomSize(Random random) { + var randomNumber = random.nextInt(0, 21); + return switch (randomNumber) { + case 0, 1, 2, 3, 4, 5 -> Size.XS; + case 6, 7, 8, 9, 10, 11, 12, 13, 14 -> Size.S; + case 15, 16, 17 -> Size.M; + case 18, 19 -> Size.L; + default -> Size.XL; + }; + } + + /** Returns a random float that expresses the area of a settlement in square kilometers. */ + public static int randomArea(Random random, Size size) { + var bounds = SETTLEMENT_CONFIGS.get(size).getArea(); + return random.nextInt(bounds.getUpper() - bounds.getLower() + 1) + bounds.getLower(); + } + + public static PointOfInterest createInstance( + Location parent, Npc npc, PointOfInterest.Type type) { + return switch (type) { + case DUNGEON -> new Dungeon(type, npc, parent); + case SHOP -> new Shop(type, npc, parent); + default -> new Amenity(type, npc, parent); + }; + } + + public static void throwIfRepeatedRequest(Generatable generatable, boolean toBeLoaded) { + if (generatable.isLoaded() == toBeLoaded) { + throw new IllegalStateException( + "Request to %s settlement '%s' even though it already is, check your logic" + .formatted(toBeLoaded ? "loaded" : "unloaded", generatable.getId())); + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("Available settlement configurations:%n".formatted()); + for (var entry : SETTLEMENT_CONFIGS.entrySet()) { + sb.append("- [%s=%s]%n".formatted(entry.getKey(), entry.getValue())); + } + return sb.toString(); + } + + @Getter + @Setter + public static class SettlementConfig { + private Bounds area; + private Bounds inhabitants; + private Map amenities; + + @Override + public String toString() { + return "{area=" + area + ", inhabitants=" + inhabitants + ", amenities=" + amenities + '}'; + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/LocationHistory.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/LocationHistory.java deleted file mode 100644 index 932e9d6..0000000 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/LocationHistory.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.hindsight.king_of_castrop_rauxel.location; - -import com.hindsight.king_of_castrop_rauxel.utils.Visitor; - -import java.time.Instant; -import java.util.List; -import java.util.Set; - - -public class LocationHistory { - private Location location; - private List visitedAt; - private Set visitors; -} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Neighbour.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Neighbour.java deleted file mode 100644 index c3d3aae..0000000 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Neighbour.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hindsight.king_of_castrop_rauxel.location; - -public interface Neighbour { -} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/PointOfInterest.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/PointOfInterest.java new file mode 100644 index 0000000..fb09b0c --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/PointOfInterest.java @@ -0,0 +1,36 @@ +package com.hindsight.king_of_castrop_rauxel.location; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.event.Event; + +import java.util.List; + +public interface PointOfInterest { + + String getId(); + + String getName(); + + String getDescription(); + + Type getType(); + + Location getParent(); + + void addAvailableAction(Event event); + + List getAvailableActions(); + + Npc getNpc(); + + String getSummary(); + + enum Type { + ENTRANCE, + MAIN_SQUARE, + SHOP, + QUEST_LOCATION, + DUNGEON + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Settlement.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Settlement.java index 0748683..c485b99 100644 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Settlement.java +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Settlement.java @@ -1,79 +1,132 @@ package com.hindsight.king_of_castrop_rauxel.location; -import static com.hindsight.king_of_castrop_rauxel.settings.LocationComponent.*; - -import com.hindsight.king_of_castrop_rauxel.action.PlayerAction; -import com.hindsight.king_of_castrop_rauxel.settings.LocationComponent; -import com.hindsight.king_of_castrop_rauxel.utils.BasicStringGenerator; +import com.hindsight.king_of_castrop_rauxel.action.PoiAction; +import com.hindsight.king_of_castrop_rauxel.characters.Inhabitant; +import com.hindsight.king_of_castrop_rauxel.cli.CliComponent; +import com.hindsight.king_of_castrop_rauxel.location.PointOfInterest.Type; +import com.hindsight.king_of_castrop_rauxel.utils.DataServices; +import com.hindsight.king_of_castrop_rauxel.utils.Generators; import java.util.stream.IntStream; +import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; @Slf4j -@ToString(callSuper = true) +@ToString(callSuper = true, includeFieldNames = false) +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) public class Settlement extends AbstractSettlement { - public Settlement() { - generate(); - logResult(); + public Settlement( + Pair worldCoords, + Pair chunkCoords, + Generators generators, + DataServices dataServices) { + super(worldCoords, chunkCoords, generators, dataServices); + generateFoundation(); + logResult(true); } @Override - public void generate() { - log.info("Generating settlement..."); - size = LocationComponent.randomSize(); - name = BasicStringGenerator.generate(this.getClass()); - generateInhabitants(); - generateAmenities(); - generatePlayerActions(); + public void load() { + var startTime = System.currentTimeMillis(); + log.info("Generating full settlement '{}'...", id); + LocationBuilder.throwIfRepeatedRequest(this, true); + loadPois(); + loadEvents(); + loadInhabitants(); + loadPlayerActions(); + setLoaded(true); + logResult(); + log.info("Generated '{}' in {} seconds", id, (System.currentTimeMillis() - startTime) / 1000.0); } - private void generateInhabitants() { - var bounds = getSettlementConfigurations().get(size).getInhabitants(); - inhabitants = random.nextInt(bounds.getMaxInclusive() - bounds.getMinInclusive() + 1); + private void generateFoundation() { + size = LocationBuilder.randomSize(random); + area = LocationBuilder.randomArea(random, size); + name = generators.nameGenerator().locationNameFrom(this.getClass()); } - private void generateAmenities() { - getSettlementConfigurations() - .get(size) - .getAmenities() - .forEach( - (k, v) -> - IntStream.range(v.getMinInclusive(), v.getMaxInclusive() + 1) - .forEach(i -> addAmenity(k))); + private void loadPois() { + var amenities = LocationBuilder.getSettlementConfig(size).getAmenities(); + for (var amenity : amenities.entrySet()) { + var bounds = amenity.getValue(); + var count = random.nextInt(bounds.getUpper() - bounds.getLower() + 1) + bounds.getLower(); + var type = amenity.getKey(); + IntStream.range(0, count).forEach(i -> addPoi(type)); + } } - private void addAmenity(AmenityType type) { - var amenity = new Amenity(type, name); - if (amenities.stream().noneMatch(a -> a.getName().equals(amenity.getName()))) { - amenities.add(amenity); + private void addPoi(Type type) { + var npc = new Inhabitant(random, generators); + var amenity = LocationBuilder.createInstance(this, npc, type); + if (pointsOfInterests.stream().noneMatch(a -> a.getName().equals(amenity.getName()))) { + pointsOfInterests.add(amenity); + inhabitants.add(npc); } else { - addAmenity(type); + revert(amenity); + addPoi(type); + } + } + + private void revert(PointOfInterest poi) { + poi.getNpc().setHome(null); + log.info( + "Skipping duplicate {} POI '{}' and generating alternative", poi.getType(), poi.getName()); + } + + /** + * Generates events and available actions for each POI. This method must be called after the + * settlement and POIs have been generated as the event can reference other POIs, etc. + */ + private void loadEvents() { + pointsOfInterests.stream() + .filter(poi -> poi.getType() != Type.DUNGEON && poi.getType() != Type.ENTRANCE) + .forEach(this::loadPrimaryEvent); + } + + private void loadPrimaryEvent(PointOfInterest poi) { + poi.getNpc().loadPrimaryEvent(); + var event = poi.getNpc().getPrimaryEvent(); + var participatingNpcs = event.getParticipantNpcs(); + for (var npc : participatingNpcs) { + npc.addSecondaryEvent(event); + npc.getHome().addAvailableAction(event); } } - private void generatePlayerActions() { - for (int i = 1; i <= amenities.size(); i++) { + private void loadInhabitants() { + var bounds = LocationBuilder.getSettlementConfig(size).getInhabitants(); + var i = random.nextInt(bounds.getUpper() - bounds.getLower() + 1) + bounds.getLower(); + inhabitantCount = Math.max(i, inhabitants.size()); + } + + private void loadPlayerActions() { + for (int i = 0; i < pointsOfInterests.size(); i++) { availableActions.add( - PlayerAction.builder() - .number(i) - .name("[%s] Go to %s".formatted(i, amenities.get(i - 1).getName())) - .location(amenities.get(i - 1)) + PoiAction.builder() + .index(i) + .name(getActionName(i)) + .poi(pointsOfInterests.get(i)) .build()); } } - /** - * Modify once higher-level locations such as countries or lands are implemented. This method is - * currently redundant but would allow referencing the parent name in its own name. - */ - @Override - public void generate(String parentName) { - generate(); + private String getActionName(int i) { + return "Go to %s%s" + .formatted( + pointsOfInterests.get(i).getName(), + CliComponent.label(pointsOfInterests.get(i).getType())); } @Override public void logResult() { - log.info("Generated: {}", this); + logResult(false); + } + + public void logResult(boolean initial) { + var action = initial || isLoaded() ? "Generated" : "Unloaded"; + var summary = initial ? this.getBriefSummary() : this.toString(); + log.info("{}: {}", action, summary); } } diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Shop.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Shop.java new file mode 100644 index 0000000..86fce1f --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Shop.java @@ -0,0 +1,73 @@ +package com.hindsight.king_of_castrop_rauxel.location; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.action.BuyAction; +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.items.Buyable; +import com.hindsight.king_of_castrop_rauxel.utils.DataServices; +import com.hindsight.king_of_castrop_rauxel.utils.Generators; + +import java.util.ArrayList; +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +@Getter +@Slf4j +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) +@ToString(callSuper = true, onlyExplicitlyIncluded = true, includeFieldNames = false) +public class Shop extends AbstractAmenity { + + private final Generators generators; + private final DataServices dataServices; + private final Shop.Type shopType; + private final List items = new ArrayList<>(); + + public Shop(PointOfInterest.Type type, Npc npc, Location parent) { + super(type, npc, parent); + this.generators = parent.getGenerators(); + this.dataServices = parent.getDataServices(); + this.shopType = Shop.Type.from(random.nextInt(0, Shop.Type.values().length)); + load(); + logResult(); + } + + @Override + public void load() { + this.name = generators.nameGenerator().shopNameFrom(this, shopType, parent.getName(), npc); + items.addAll(dataServices.consumableService().getConsumablesByType(shopType)); + loadPlayerActions(); + setLoaded(true); + } + + private void loadPlayerActions() { + items.forEach(item -> availableActions.add(new BuyAction(availableActions.size() + 1, item))); + } + + @Override + public List getAvailableActions() { + return availableActions; + } + + @Override + public void logResult() { + log.info("Generated: {}", this); + } + + /** + * The type of shop which determines products on offer. When adding new values here, make sure to + * also add a corresponding entry in the TXT file containing names, otherwise fallback names will + * be used. + */ + @Getter + public enum Type { + GENERAL, + ALCHEMY; + + public static Type from(int i) { + return values()[i]; + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Size.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Size.java new file mode 100644 index 0000000..89ee26b --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Size.java @@ -0,0 +1,20 @@ +package com.hindsight.king_of_castrop_rauxel.location; + +import lombok.Getter; + +@Getter +public enum Size { + XS("Very small", 0), + S("Small", 1), + M("Medium", 2), + L("Large", 3), + XL("Very large", 4); + + private final String name; + private final int ordinal; + + Size(String s, int i) { + this.name = s; + this.ordinal = i; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Visitable.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Visitable.java similarity index 53% rename from src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Visitable.java rename to src/main/java/com/hindsight/king_of_castrop_rauxel/location/Visitable.java index 167e850..94b3214 100644 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Visitable.java +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/location/Visitable.java @@ -1,4 +1,6 @@ -package com.hindsight.king_of_castrop_rauxel.utils; +package com.hindsight.king_of_castrop_rauxel.location; + +import com.hindsight.king_of_castrop_rauxel.characters.Visitor; public interface Visitable { boolean hasBeenVisited(); diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/player/Player.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/player/Player.java deleted file mode 100644 index ede98e8..0000000 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/player/Player.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.hindsight.king_of_castrop_rauxel.player; - -import com.hindsight.king_of_castrop_rauxel.location.Location; -import com.hindsight.king_of_castrop_rauxel.utils.Visitor; -import lombok.Getter; -import lombok.NonNull; -import lombok.Setter; - -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; - -@Getter -public class Player implements Visitor { - private final String id; - private final String name; - @Setter private int gold; - @Setter private int level; - @Setter private int age; - @Setter private int activityPoints; - private Location currentLocation; - private Set visitedLocations = new HashSet<>(); - - public Player(String name, @NonNull Location currentLocation) { - this.name = name; - id = UUID.randomUUID().toString(); - setCurrentLocation(currentLocation); - } - - public void setCurrentLocation(Location currentLocation) { - visitedLocations.add(this.currentLocation); - this.currentLocation = currentLocation; - } -} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/settings/LocationComponent.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/settings/LocationComponent.java deleted file mode 100644 index 5ad9b6b..0000000 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/settings/LocationComponent.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.hindsight.king_of_castrop_rauxel.settings; - -import java.util.*; -import lombok.*; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -public class LocationComponent { - - private static final Random random = new Random(); - - @Getter - private static Map settlementConfigurations = new EnumMap<>(Size.class); - - public enum Size { - XS, - S, - M, - L, - XL - } - - public enum AmenityType { - ENTRANCE, - SHOP, - QUEST_LOCATION, - } - - public LocationComponent() { - SettlementConfig xs = new SettlementConfig(); - SettlementConfig s = new SettlementConfig(); - SettlementConfig m = new SettlementConfig(); - SettlementConfig l = new SettlementConfig(); - SettlementConfig xl = new SettlementConfig(); - - xs.inhabitants = new Bounds(1, 10); - s.inhabitants = new Bounds(11, 100); - m.inhabitants = new Bounds(101, 1000); - l.inhabitants = new Bounds(1001, 10000); - xl.inhabitants = new Bounds(10000, 100000); - - xs.amenities = new EnumMap<>(AmenityType.class); - s.amenities = new EnumMap<>(AmenityType.class); - m.amenities = new EnumMap<>(AmenityType.class); - l.amenities = new EnumMap<>(AmenityType.class); - xl.amenities = new EnumMap<>(AmenityType.class); - - xs.amenities.put(AmenityType.ENTRANCE, new Bounds(1, 1)); - s.amenities.put(AmenityType.ENTRANCE, new Bounds(1, 2)); - m.amenities.put(AmenityType.ENTRANCE, new Bounds(1, 1)); - l.amenities.put(AmenityType.ENTRANCE, new Bounds(1, 1)); - xl.amenities.put(AmenityType.ENTRANCE, new Bounds(4, 8)); - - xs.amenities.put(AmenityType.SHOP, new Bounds(0, 1)); - s.amenities.put(AmenityType.SHOP, new Bounds(1, 3)); - m.amenities.put(AmenityType.SHOP, new Bounds(3, 6)); - l.amenities.put(AmenityType.SHOP, new Bounds(5, 10)); - xl.amenities.put(AmenityType.SHOP, new Bounds(8, 14)); - - xs.amenities.put(AmenityType.QUEST_LOCATION, new Bounds(0, 2)); - s.amenities.put(AmenityType.QUEST_LOCATION, new Bounds(0, 2)); - m.amenities.put(AmenityType.QUEST_LOCATION, new Bounds(0, 2)); - l.amenities.put(AmenityType.QUEST_LOCATION, new Bounds(0, 2)); - xl.amenities.put(AmenityType.QUEST_LOCATION, new Bounds(10, 20)); - - settlementConfigurations.put(Size.XS, xs); - settlementConfigurations.put(Size.S, s); - settlementConfigurations.put(Size.M, m); - settlementConfigurations.put(Size.L, l); - settlementConfigurations.put(Size.XL, xl); - } - - public static Size randomSize() { - // TODO: Allow for more fine-grained control of probabilities - var rand = random.nextInt(0, 100) / 10; - log.info("Random int: {}", rand); - return switch (Integer.toString(rand)) { - case "1", "2", "3", "4" -> Size.XS; - case "5", "6" -> Size.S; - case "7", "8" -> Size.M; - case "9" -> Size.L; - default -> Size.XL; - }; - } - - @Getter - @Setter - public static class SettlementConfig { - private Bounds inhabitants; - private Map amenities; - } - - @Getter - @Setter - @AllArgsConstructor - public static class Bounds { - private int minInclusive; - private int maxInclusive; - } -} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicEventGenerator.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicEventGenerator.java new file mode 100644 index 0000000..bd4d809 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicEventGenerator.java @@ -0,0 +1,180 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.event.*; +import com.hindsight.king_of_castrop_rauxel.location.PointOfInterest; +import java.util.*; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class BasicEventGenerator implements EventGenerator { + + private static final String NPC_DISMISSIVE = "npc-dismissive"; + private static final String FALLBACK_ONE_LINER = "Hum?"; + private final FolderReader folderReader; + private final TxtReader txtReader; + private final YamlReader yamlReader = new YamlReader(); + private final PlaceholderProcessor processor = new PlaceholderProcessor(); + private Random random; + + public BasicEventGenerator(FolderReader folderReader) { + this.folderReader = folderReader; + this.txtReader = new TxtReader(folderReader.getSingleStepEventFolder()); + } + + public void initialise(Random parentRandom) { + random = parentRandom; + } + + @Override + public Event singleStepDialogue(Npc npc) { + var text = readRandomLineFromFile(NPC_DISMISSIVE); + var interactions = List.of(new Interaction(text, List.of())); + var dialogues = List.of(new Dialogue(interactions)); + var participants = List.of(new Participant(npc, dialogues)); + process(dialogues, npc, null); + return new DialogueEvent(new EventDetails(), participants, true); + } + + private String readRandomLineFromFile(String fileName) { + var result = txtReader.read(fileName); + if (!result.isEmpty()) { + return txtReader.getRandom(result, random).trim(); + } + log.error("No file found for path name '%s'".formatted(fileName)); + return FALLBACK_ONE_LINER; + } + + @Override + public Event multiStepDialogue(Npc npc) { + var eventPath = folderReader.getRandomEventPath(Event.Type.DIALOGUE, random); + var eventDto = yamlReader.read(eventPath); + var dialogues = eventDto.participantData.get(Role.EVENT_GIVER); + var participants = List.of(new Participant(npc, dialogues)); + var eventDetails = eventDto.eventDetails; + initialiseRewards(eventDetails); + processPlaceholders(eventDto, participants); + return new DialogueEvent(eventDetails, participants, true); + } + + @Override + public Event deliveryEvent(Npc npc) { + var eventPath = folderReader.getRandomEventPath(Event.Type.REACH, random); + var eventDto = yamlReader.read(eventPath); + var giverNpcDialogues = eventDto.participantData.get(Role.EVENT_GIVER); + var giverParticipant = new Participant(npc, giverNpcDialogues); + var targetNpcDialogues = eventDto.participantData.get(Role.EVENT_TARGET); + var targetPoi = tryToFindAPoi(npc); + if (targetPoi != null) { + var targetNpc = targetPoi.getNpc(); + var targetParticipant = new Participant(targetNpc, Role.EVENT_TARGET, targetNpcDialogues); + var participants = List.of(giverParticipant, targetParticipant); + setEventGiver(eventDto.eventDetails, npc); + initialiseRewards(eventDto.eventDetails); + processPlaceholders(eventDto, participants); + return new ReachEvent(eventDto.eventDetails, participants); + } + return null; + } + + private PointOfInterest tryToFindAPoi(Npc npc) { + var availablePois = npc.getHome().getParent().getPointsOfInterest(); + for (int i = 0; i < availablePois.size(); i++) { + var poi = findPoiInSameLocation(npc, availablePois); + if (poi != null) { + return poi; + } + } + log.warn("Cannot generate DeliveryEvent - no POI available for {}", npc); + return null; + } + + private PointOfInterest findPoiInSameLocation(Npc npc, List pois) { + var randomNumber = random.nextInt(0, pois.size()); + var poi = pois.get(randomNumber); + var hasNoNpc = poi.getNpc() == null; + var isSamePoi = npc.getHome().equals(poi); + var isNotEligible = poi.getType() == PointOfInterest.Type.DUNGEON; + if (hasNoNpc || isSamePoi || isNotEligible) { + return null; + } + return poi; + } + + private void setEventGiver(EventDetails eventDetails, Npc npc) { + eventDetails.setEventGiver(npc); + } + + private void initialiseRewards(EventDetails eventDetails) { + for (var reward : eventDetails.getRewards()) { + reward.load(random); + log.info("Initialised reward of {} for {}", reward, eventDetails.getId()); + } + } + + private void processPlaceholders(EventDto eventDto, List participants) { + var giverNpc = getNpc(participants, Role.EVENT_GIVER); + var targetNpc = getNpc(participants, Role.EVENT_TARGET); + var gDialogue = eventDto.participantData.get(Role.EVENT_GIVER); + var tDialogue = eventDto.participantData.get(Role.EVENT_TARGET); + process(eventDto, giverNpc, targetNpc); + process(gDialogue, giverNpc, targetNpc); + process(tDialogue, giverNpc, targetNpc); + var dialoguesList = tDialogue == null ? List.of(gDialogue) : List.of(gDialogue, tDialogue); + process(dialoguesList, eventDto); + } + + private Npc getNpc(List participants, Role role) { + return participants.stream() + .filter(p -> p.role() == role) + .map(Participant::npc) + .findFirst() + .orElse(null); + } + + private void process(EventDto toProcess, Npc npc, Npc targetNpc) { + var eventDetails = toProcess.eventDetails; + var tAboutProcessed = process(eventDetails.getAboutTarget(), npc, targetNpc); + eventDetails.setAboutTarget(tAboutProcessed); + var gAboutProcessed = process(eventDetails.getAboutGiver(), npc, targetNpc); + eventDetails.setAboutGiver(gAboutProcessed); + } + + private void process(List toProcess, Npc npc, Npc targetNpc) { + if (toProcess == null) { + return; + } + for (var dialogue : toProcess) { + for (var interaction : dialogue.getInteractions()) { + interaction.setText(process(interaction.getText(), npc, targetNpc)); + process(interaction, npc, targetNpc); + } + } + } + + private void process(List> toProcess, EventDto eventDto) { + for (var dialogues : toProcess) { + for (var dialogue : dialogues) { + for (var interaction : dialogue.getInteractions()) { + interaction.setText(processor.process(interaction.getText(), eventDto.eventDetails)); + } + } + } + } + + private void process(Interaction toProcess, Npc npc, Npc targetNpc) { + var actions = toProcess.getActions(); + actions.forEach(action -> action.setName(process(action.getName(), npc, targetNpc))); + } + + private String process(String toProcess, Npc npc, Npc targetNpc) { + if (toProcess.isEmpty()) { + return toProcess; + } + if (targetNpc == null) { + return processor.process(toProcess, npc); + } else { + return processor.process(toProcess, npc, targetNpc); + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicNameGenerator.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicNameGenerator.java new file mode 100644 index 0000000..1306a1a --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicNameGenerator.java @@ -0,0 +1,190 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import static com.hindsight.king_of_castrop_rauxel.location.PointOfInterest.Type; + +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.location.AbstractAmenity; +import com.hindsight.king_of_castrop_rauxel.encounter.DungeonDetails; +import com.hindsight.king_of_castrop_rauxel.location.Amenity; +import com.hindsight.king_of_castrop_rauxel.location.Shop; +import com.hindsight.king_of_castrop_rauxel.location.Size; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class BasicNameGenerator implements NameGenerator { + + private static final String SUFFIX_MIDDLE = "--middle"; + private static final String[] SUFFIXES = new String[] {"--start", SUFFIX_MIDDLE, "--end"}; + private static final String BASIC_ENEMY_SUFFIX = "--type-prefix"; + private static final String NONDESCRIPT = "Nondescript "; + private static final String HYPHEN = "-"; + private static final String FIRST_NAME = "first_name"; + private static final String LAST_NAME = "last_name"; + private final TxtReader txtReader; + private final PlaceholderProcessor processor; + private Random random; + + public BasicNameGenerator(FolderReader folderReader) { + this.txtReader = new TxtReader(folderReader.getNamesFolder()); + processor = new PlaceholderProcessor(); + } + + public void initialise(Random parentRandom) { + this.random = parentRandom; + } + + @Override + public String locationNameFrom(Class clazz) { + return locationNameFrom(clazz, null, null, null, null); + } + + @Override + public String locationNameFrom( + Class clazz, AbstractAmenity amenity, Size parentSize, String parentName, Npc inhabitant) { + var type = amenity == null ? null : amenity.getType(); + var withType = type == null ? "" : HYPHEN + type.name().toLowerCase(); + var withSize = parentSize == null ? "" : HYPHEN + parentSize.name().toLowerCase(); + var className = clazz.getSimpleName().toLowerCase(); + var pathNameWithTypeAndSize = "%s%s%s".formatted(className, withType, withSize); + var pathNameWithTypeOnly = "%s%s".formatted(className, withType); + var words = new ArrayList(); + log.debug( + "Attempting to generate string for class '{}' with '{}' or '{}'", + className, + pathNameWithTypeAndSize != null ? pathNameWithTypeAndSize : "null", + pathNameWithTypeOnly != null ? pathNameWithTypeOnly : "null"); + + randomWordForEachSuffix(words, pathNameWithTypeAndSize); + randomWordForEachSuffix(words, pathNameWithTypeOnly); + randomWordFromSpecificFile(words, pathNameWithTypeAndSize); + randomWordFromSpecificFile(words, pathNameWithTypeOnly); + setFallbackStringIfListEmpty(words, className); + + processFileNamePlaceholders(words, pathNameWithTypeAndSize, type); + processor.process(words, parentName, inhabitant, amenity); + return String.join("", words); + } + + @Override + public String shopNameFrom(AbstractAmenity amenity, Shop.Type type, String parentName, Npc inhabitant) { + var className = amenity.getClass().getSimpleName().toLowerCase(); + var basePath = Amenity.class.getSimpleName() + HYPHEN + className; + var pathName = basePath + HYPHEN + type.name().toLowerCase(); + var words = new ArrayList(); + log.debug("Attempting to generate shop name for '{}'", pathName); + randomWordFromSpecificFile(words, pathName); + setFallbackStringIfListEmpty(words, className); + processor.process(words, parentName, inhabitant, amenity); + return words.get(0).trim(); + } + + @Override + public String npcFirstNameFrom(Class clazz) { + return npcNameFrom(true, false, clazz); + } + + @Override + public String npcLastNameFrom(Class clazz) { + return npcNameFrom(false, true, clazz); + } + + private String npcNameFrom(boolean firstName, boolean lastName, Class clazz) { + var className = clazz.getSimpleName().toLowerCase(); + var words = new ArrayList(); + log.debug( + "Attempting to generate {} {} {} for class '{}'", + firstName && lastName ? "first and last name" : "", + firstName && !lastName ? "first name" : "", + !firstName && lastName ? "last name" : "", + className); + + if (firstName) { + randomWordFromSpecificFile(words, className + HYPHEN + FIRST_NAME); + } + if (lastName) { + randomWordFromSpecificFile(words, className + HYPHEN + LAST_NAME); + } + setFallbackStringIfListEmpty(words, className); + + return String.join(" ", words).trim(); + } + + @Override + public String enemyNameFrom(Class clazz, DungeonDetails.Type type) { + var className = clazz.getSimpleName().toLowerCase(); + var pathName = className + BASIC_ENEMY_SUFFIX; + var words = new ArrayList(); + log.debug("Attempting to generate {} of type {}", className, type); + randomWordFromSpecificFile(words, pathName); + setFallbackStringIfListEmpty(words, className); + return words.get(0).trim() + " " + type.name().toLowerCase(); + } + + @Override + public String dungeonNameFrom(Class clazz, DungeonDetails.Type type) { + var className = clazz.getSimpleName().toLowerCase(); + var pathNameWithType = className + HYPHEN + type.name().toLowerCase(); + var words = new ArrayList(); + log.debug("Attempting to generate dungeon name for class '{}'", className); + randomWordFromSpecificFile(words, pathNameWithType); + randomWordFromSpecificFile(words, className); + setFallbackStringIfListEmpty(words, className); + return words.get(0).trim(); + } + + // TODO: Think about what descriptions would make sense for different types of dungeons + @Override + public String dungeonDescriptionFrom(Class ignoredClass, DungeonDetails.Type ignoredType) { + return "A dark and foreboding place"; + } + + /** + * Adds one, random word from each file with the given suffixes to the list of words, with there + * being a 25% chance that a word from the file with the suffix '--middle' is added. + */ + private void randomWordForEachSuffix(List words, String partialFileName) { + if (words.isEmpty()) { + for (String suffix : SUFFIXES) { + var result = txtReader.read(partialFileName + suffix); + if (!result.isEmpty() && (!suffix.equals(SUFFIX_MIDDLE) || random.nextInt(3) == 0)) { + words.add(txtReader.getRandom(result, random)); + } + } + } + } + + /** Adds one, random word from the file with the given name to the list of words. */ + private void randomWordFromSpecificFile(List words, String fileName) { + if (words.isEmpty()) { + var result = txtReader.read(fileName); + if (!result.isEmpty()) { + words.add(txtReader.getRandom(result, random)); + } + } + } + + private void setFallbackStringIfListEmpty(List words, String className) { + if (words.isEmpty()) { + log.error("No input files found for class '{}'", className); + words.add(className.toUpperCase() + " " + random.nextInt(1000)); + } + } + + private void processFileNamePlaceholders(List words, String pathName, Type type) { + if (words.get(0).startsWith(HYPHEN)) { + var result = txtReader.read(pathName + words.get(0)); + if (result.isEmpty()) { + log.warn("Failed to replace '{}' at path '{}'", words.get(0), pathName); + var fallbackName = type != null ? type.name() : pathName; + words.set(0, NONDESCRIPT + fallbackName + " " + random.nextInt(1000)); + } else { + var randomWord = txtReader.getRandom(result, random); + log.info("Replacing '{}' with word '{}'", words.get(0), randomWord); + words.set(0, randomWord); + } + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicStringGenerator.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicStringGenerator.java deleted file mode 100644 index 33ce8cb..0000000 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicStringGenerator.java +++ /dev/null @@ -1,129 +0,0 @@ -package com.hindsight.king_of_castrop_rauxel.utils; - -import lombok.experimental.UtilityClass; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.RandomStringUtils; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.List; -import java.util.Random; - -import static com.hindsight.king_of_castrop_rauxel.settings.LocationComponent.*; - -@Slf4j -@UtilityClass -public class BasicStringGenerator { - private static final String FOLDER = "names" + System.getProperty("file.separator"); - private static final String SUFFIX_MIDDLE = "--middle"; - private static final String[] SUFFIXES = new String[] {"--start", SUFFIX_MIDDLE, "--end"}; - private static final String FILE_EXTENSION = ".txt"; - public static final String NONDESCRIPT = "Nondescript "; - private final Random random = new Random(); - public static final String HYPHEN = "-"; - public static final String PLACEHOLDER_PARENT_NAME = "%P"; - public static final String PLACEHOLDER_OWNER_NAME = "%O"; - - public static String generate(Class clazz) { - return generate(null, null, clazz); - } - - public static String generate(AmenityType type, Class clazz) { - return generate(type, null, clazz); - } - - public static String generate(String parentName, Class clazz) { - return generate(null, parentName, clazz); - } - - public static String generate(AmenityType type, String parentName, Class clazz) { - var typeName = type == null ? "" : HYPHEN + type.name(); - var pathName = "%s%s".formatted(getClassName(clazz), typeName); - log.debug("Generating string for path '{}'", pathName); - List words = new ArrayList<>(); - - // Loop through files with suffixes - for (String suffix : SUFFIXES) { - var result = readWordsFromFile(pathName + suffix); - if (!result.isEmpty() && (!suffix.equals(SUFFIX_MIDDLE) || random.nextInt(3) == 0)) { - words.add(getRandomWord(result)); - } - } - - // Loop through file without suffix - if (words.isEmpty()) { - var result = readWordsFromFile(pathName); - if (result.isEmpty()) { - log.warn("No input files found for class '{}'", pathName); - words.add(NONDESCRIPT + pathName.toLowerCase()); - } else { - words.add(getRandomWord(result).trim()); - } - } - - // Process and return words - processingNewWordPlaceholders(words, pathName); - processParentNamePlaceholders(words, parentName); - return String.join("", words); - } - - private String getClassName(Class clazz) { - return clazz.getSimpleName(); - } - - private static List readWordsFromFile(String filename) { - InputStream inputStream = - BasicStringGenerator.class - .getClassLoader() - .getResourceAsStream(FOLDER + filename + FILE_EXTENSION); - if (inputStream != null) { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { - return reader.lines().map(String::trim).toList(); - } catch (IOException e) { - log.warn("File '{}' not found", filename); - } - } - return new ArrayList<>(); - } - - private static String getRandomWord(List words) { - int randomIndex = random.nextInt(words.size()); - return words.get(randomIndex); - } - - private static void processingNewWordPlaceholders(List words, String pathName) { - if (words.get(0).startsWith(HYPHEN)) { - var result = readWordsFromFile(pathName + words.get(0)); - if (result.isEmpty()) { - log.warn("No input files found for path '{}'", pathName + words.get(0)); - words.set(0, NONDESCRIPT + pathName.toLowerCase()); - } else { - var randomWord = getRandomWord(result).trim(); - log.info("Replacing '{}' with random word '{}'", words.get(0), randomWord); - words.set(0, randomWord); - } - } - } - - private static void processParentNamePlaceholders(List words, String parentName) { - for (String word : words) { - if (word.contains(PLACEHOLDER_OWNER_NAME) && parentName != null) { - log.info("Replacing '{}' with class name '{}'", word, parentName); - words.set(words.indexOf(word), word.replace(PLACEHOLDER_OWNER_NAME, parentName)); - } - } - } - - public static String generatePlaceholder(String className) { - log.info("Using fallback generator is being used '{}'", className); - int length = 5; - boolean useLetters = true; - boolean useNumbers = false; - return "%s %s" - .formatted( - className, RandomStringUtils.random(length, useLetters, useNumbers).toUpperCase()); - } -} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicTerrainGenerator.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicTerrainGenerator.java new file mode 100644 index 0000000..c3ee83e --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/BasicTerrainGenerator.java @@ -0,0 +1,45 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import java.util.Random; + +import com.hindsight.king_of_castrop_rauxel.world.Coordinates; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.WORLD_CENTER; +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.WORLD_SIZE; + +/** + * Currently only used to generate difficulty values for each chunk based on their distance to the + * centre of the world. Over time, this class should use Perlin noise to generate terrain properties + * (such as temperature, continentalness, humidity) and translate them into biomes which affect + * settlement and POI generation including enemy types and difficulty values (and possibly also to + * generate an ASCII art visual map). + */ +@Slf4j +@RequiredArgsConstructor(onConstructor = @__(@Autowired)) +public class BasicTerrainGenerator implements TerrainGenerator { + + private final int[][] targetLevel = new int[WORLD_SIZE][WORLD_SIZE]; + + @Override + public void initialise(Random random) { + createTargetLevelMatrix(); + } + + private void createTargetLevelMatrix() { + for (int row = 0; row < WORLD_SIZE; row++) { + for (int col = 0; col < WORLD_SIZE; col++) { + int distance = Math.max(Math.abs(row - WORLD_CENTER) + Math.abs(col - WORLD_CENTER), 1); + targetLevel[row][col] = distance; + } + } + } + + public int getTargetLevel(Coordinates coordinates) { + var chunkDifficulty = this.targetLevel[coordinates.wX()][coordinates.wY()]; + log.debug("Generated difficulty {} for {}", chunkDifficulty, coordinates.globalToString()); + return chunkDifficulty; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/DataServices.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/DataServices.java new file mode 100644 index 0000000..19c53c3 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/DataServices.java @@ -0,0 +1,5 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import com.hindsight.king_of_castrop_rauxel.items.ConsumableService; + +public record DataServices(ConsumableService consumableService) {} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/EventDto.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/EventDto.java new file mode 100644 index 0000000..7f930a4 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/EventDto.java @@ -0,0 +1,18 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import com.hindsight.king_of_castrop_rauxel.event.Dialogue; +import com.hindsight.king_of_castrop_rauxel.event.EventDetails; +import com.hindsight.king_of_castrop_rauxel.event.Role; +import java.util.List; +import java.util.Map; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class EventDto { + + EventDetails eventDetails; + Map> participantData; +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/EventGenerator.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/EventGenerator.java new file mode 100644 index 0000000..28a6e31 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/EventGenerator.java @@ -0,0 +1,13 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.event.Event; + +public interface EventGenerator extends Generator { + + Event singleStepDialogue(Npc npc); + + Event multiStepDialogue(Npc npc); + + Event deliveryEvent(Npc npc); +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/FolderReader.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/FolderReader.java new file mode 100644 index 0000000..a68b026 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/FolderReader.java @@ -0,0 +1,119 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import com.hindsight.king_of_castrop_rauxel.configuration.AppProperties; +import com.hindsight.king_of_castrop_rauxel.event.Event; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.*; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +@Slf4j +public class FolderReader { + + private static final String BASE_EVENT_FOLDER = "events"; + private static final String SINGLE_STEP_FOLDER = "single-step"; + private static final String MULTI_STEP_FOLDER = "multi-step"; + private static final String REACH_FOLDER = "reach"; + private static final String BASE_NAME_FOLDER = "names"; + + @Getter public String fileSeparator; + private Map> eventFilePaths; + + public FolderReader() { + determineCorrectFileSeparator(); + loadEventFilesMap(); + } + + private void determineCorrectFileSeparator() { + if (Boolean.TRUE.equals(AppProperties.getIsRunningAsJar())) { + fileSeparator = "/"; + } else { + fileSeparator = System.getProperty("file.separator"); + } + log.info("File separator: '{}'", fileSeparator); + } + + public String getNamesFolder() { + return BASE_NAME_FOLDER + fileSeparator; + } + + public String getSingleStepEventFolder() { + return BASE_EVENT_FOLDER + fileSeparator + SINGLE_STEP_FOLDER + fileSeparator; + } + + public String getRandomEventPath(Event.Type type, Random random) { + var paths = eventFilePaths.get(type); + var randomIndex = random.nextInt(0, paths.size()); + return paths.get(randomIndex); + } + + @SuppressWarnings("SwitchStatementWithTooFewBranches") + private void loadEventFilesMap() { + eventFilePaths = new EnumMap<>(Event.Type.class); + for (var t : Event.Type.values()) { + var subFolder = + switch (t) { + case REACH -> REACH_FOLDER + fileSeparator; + default -> MULTI_STEP_FOLDER + fileSeparator; + }; + var folder = BASE_EVENT_FOLDER + fileSeparator + subFolder; + var result = getAllFileNamesInside(folder); + eventFilePaths.put(t, result); + } + log.info("Loaded event files:"); + for (var e : eventFilePaths.entrySet()) { + log.info("> Type: " + e.getKey()); + e.getValue().forEach(x -> log.info(" - " + x)); + } + } + + @SuppressWarnings("CallToPrintStackTrace") + private List getAllFileNamesInside(String folder) { + if (Boolean.TRUE.equals(AppProperties.getIsRunningAsJar())) { + return readFromJar(folder); + } else { + try { + return readFromResourceFolder(folder); + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new IllegalStateException("Couldn't read files from non-JAR env. resource folder", e); + } + } + } + + @SuppressWarnings("CallToPrintStackTrace") + private static List readFromJar(String folder) { + try { + var resolver = new PathMatchingResourcePatternResolver(); + var resources = resolver.getResources("classpath*:%s*.yml".formatted(folder)); + return Arrays.stream(resources) + .filter(resource -> resource != null && resource.getFilename() != null) + .map(resource -> folder.concat(resource.getFilename())) + .toList(); + } catch (IOException e) { + e.printStackTrace(); + } + throw new IllegalArgumentException("Resource folder '%s' not found".formatted(folder)); + } + + @SuppressWarnings("CallToPrintStackTrace") + private List readFromResourceFolder(String folder) throws URISyntaxException { + var resourceUrl = getClass().getClassLoader().getResource(folder); + if (resourceUrl != null) { + var startPath = Paths.get(resourceUrl.toURI()); + try (var stream = Files.walk(startPath)) { + return stream + .filter(Files::isRegularFile) + .map(a -> folder.concat(a.getFileName().toString())) + .toList(); + } catch (IOException e) { + e.printStackTrace(); + } + } + throw new IllegalArgumentException("Folder '%s' not found in JAR".formatted(folder)); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Generatable.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Generatable.java deleted file mode 100644 index 26335d0..0000000 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Generatable.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.hindsight.king_of_castrop_rauxel.utils; - -public interface Generatable { - void generate(); - void generate(String parentName); - void logResult(); -} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Generator.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Generator.java new file mode 100644 index 0000000..52480a2 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Generator.java @@ -0,0 +1,7 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import java.util.Random; + +public interface Generator { + void initialise(Random random); +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Generators.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Generators.java new file mode 100644 index 0000000..91e6f6d --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Generators.java @@ -0,0 +1,18 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import java.util.List; +import java.util.Random; + +public record Generators( + NameGenerator nameGenerator, EventGenerator eventGenerator, TerrainGenerator terrainGenerator) { + + private List getAll() { + return List.of(nameGenerator, eventGenerator, terrainGenerator); + } + + public void initialiseAll(Random random) { + for (var generator : getAll()) { + generator.initialise(random); + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/NameGenerator.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/NameGenerator.java new file mode 100644 index 0000000..685e444 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/NameGenerator.java @@ -0,0 +1,25 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.encounter.DungeonDetails; +import com.hindsight.king_of_castrop_rauxel.location.*; + +public interface NameGenerator extends Generator { + + String locationNameFrom(Class clazz); + + String locationNameFrom( + Class clazz, AbstractAmenity amenity, Size parentSize, String parentName, Npc inhabitant); + + String shopNameFrom(AbstractAmenity amenity, Shop.Type type, String parentName, Npc inhabitant); + + String npcFirstNameFrom(Class clazz); + + String npcLastNameFrom(Class clazz); + + String enemyNameFrom(Class clazz, DungeonDetails.Type type); + + String dungeonNameFrom(Class clazz, DungeonDetails.Type type); + + String dungeonDescriptionFrom(Class clazz, DungeonDetails.Type type); +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/PlaceholderProcessor.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/PlaceholderProcessor.java new file mode 100644 index 0000000..b34733c --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/PlaceholderProcessor.java @@ -0,0 +1,98 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import com.hindsight.king_of_castrop_rauxel.characters.Npc; +import com.hindsight.king_of_castrop_rauxel.event.EventDetails; +import com.hindsight.king_of_castrop_rauxel.location.AbstractAmenity; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * The purpose of this class is to process placeholders in strings at runtime. The order in which + * they are processed is important: Most specific placeholders must be processed before more generic + * ones e.g. &OF must be processed before &O. + */ +@Slf4j +public class PlaceholderProcessor { + + // TODO: Allow injecting player name in PlaceholderProcessor with &PL + private static final String PLACEHOLDER_PARENT = "&P"; + private static final String PLACEHOLDER_LOCATION = "&L"; + private static final String PLACEHOLDER_POI_NAME = "&I"; + private static final String PLACEHOLDER_OWNER_FIRST_NAME = "&OF"; + private static final String PLACEHOLDER_OWNER = "&O"; + private static final String PLACEHOLDER_TARGET_NPC_FIRST_NAME = "&TOF"; + private static final String PLACEHOLDER_TARGET_NPC = "&TO"; + private static final String PLACEHOLDER_TARGET_POI = "&TI"; + private static final String PLACEHOLDER_TARGET_LOCATION = "&TL"; + private static final String PLACEHOLDER_REWARD = "&R"; + + /** Used to process events, in particular actions, interactions, and eventDetails. */ + public String process(String toProcess, Npc owner, Npc targetNpc) { + toProcess = processOwnerPlaceholders(toProcess, owner); + toProcess = toProcess.replace(PLACEHOLDER_TARGET_NPC_FIRST_NAME, targetNpc.getFirstName()); + toProcess = toProcess.replace(PLACEHOLDER_TARGET_NPC, targetNpc.getName()); + toProcess = toProcess.replace(PLACEHOLDER_TARGET_POI, targetNpc.getHome().getName()); + toProcess = + toProcess.replace(PLACEHOLDER_TARGET_LOCATION, targetNpc.getHome().getParent().getName()); + return toProcess; + } + + /** Used to process events, in particular actions and interactions. */ + public String process(String toProcess, Npc owner) { + return processOwnerPlaceholders(toProcess, owner); + } + + private String processOwnerPlaceholders(String toProcess, Npc owner) { + toProcess = toProcess.replace(PLACEHOLDER_PARENT, owner.getHome().getName()); + toProcess = toProcess.replace(PLACEHOLDER_LOCATION, owner.getHome().getParent().getName()); + toProcess = toProcess.replace(PLACEHOLDER_POI_NAME, owner.getName()); + toProcess = toProcess.replace(PLACEHOLDER_OWNER_FIRST_NAME, owner.getFirstName()); + toProcess = toProcess.replace(PLACEHOLDER_OWNER, owner.getName()); + return toProcess; + } + + /** Used to process events, in particular interactions. */ + public String process(String toProcess, EventDetails eventDetails) { + var rewards = eventDetails.getRewards(); + if (rewards == null || rewards.isEmpty()) { + return toProcess.replace(PLACEHOLDER_REWARD, "none"); + } + var rewardsString = new StringBuilder(); + for (var reward : rewards) { + rewardsString.append(reward.toString()).append(", "); + } + rewardsString.setLength(rewardsString.length() - 2); + return toProcess.replace(PLACEHOLDER_REWARD, rewardsString); + } + + /** Used to process Location and PointOfInterest names. */ + public void process( + List toProcess, String parentName, Npc inhabitant, AbstractAmenity amenity) { + for (String word : toProcess) { + injectParentName(toProcess, word, parentName); + injectInhabitantName(toProcess, word, inhabitant, amenity); + } + } + + private void injectParentName(List toProcess, String word, String parentName) { + if (word.contains(PLACEHOLDER_PARENT) && parentName != null) { + log.info("Injecting parent class name '{}' into '{}'", parentName, word); + toProcess.set(toProcess.indexOf(word), word.replace(PLACEHOLDER_PARENT, parentName)); + } + } + + private void injectInhabitantName( + List toProcess, String word, Npc inhabitant, AbstractAmenity amenity) { + if (!word.contains(PLACEHOLDER_OWNER)) { + return; + } + if (inhabitant == null) { + throw new IllegalStateException("No inhabitant at: %s".formatted(amenity.getSummary())); + } else if (inhabitant.getHome() != amenity) { + throw new IllegalStateException("'%s' already has a home".formatted(inhabitant.getName())); + } + log.info("Injecting inhabitant first name '{}' into '{}'", inhabitant.getFirstName(), word); + toProcess.set( + toProcess.indexOf(word), word.replaceFirst(PLACEHOLDER_OWNER, inhabitant.getFirstName())); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/TerrainGenerator.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/TerrainGenerator.java new file mode 100644 index 0000000..17e6e72 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/TerrainGenerator.java @@ -0,0 +1,11 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import com.hindsight.king_of_castrop_rauxel.world.Coordinates; +import java.util.Random; + +public interface TerrainGenerator extends Generator { + @Override + void initialise(Random random); + + int getTargetLevel(Coordinates coordinates); +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/TxtReader.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/TxtReader.java new file mode 100644 index 0000000..c60ac73 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/TxtReader.java @@ -0,0 +1,39 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class TxtReader { + + private static final String FILE_EXTENSION = ".txt"; + private final String folder; + + public TxtReader(String folder) { + this.folder = folder; + } + + List read(String fileName) { + var uri = folder + fileName + FILE_EXTENSION; + var inputStream = getClass().getClassLoader().getResourceAsStream(uri); + if (inputStream != null) { + try (var reader = new BufferedReader(new InputStreamReader(inputStream))) { + return reader.lines().map(String::trim).toList(); + } catch (IOException e) { + log.error("File '{}' exists but there was an error reading it", fileName, e); + return new ArrayList<>(); + } + } + return new ArrayList<>(); + } + + String getRandom(List lines, Random random) { + int randomIndex = random.nextInt(lines.size()); + return lines.get(randomIndex); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Visitor.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Visitor.java deleted file mode 100644 index 9e79078..0000000 --- a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/Visitor.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hindsight.king_of_castrop_rauxel.utils; - -public interface Visitor { -} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/YamlReader.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/YamlReader.java new file mode 100644 index 0000000..4ef0b52 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/utils/YamlReader.java @@ -0,0 +1,46 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import com.hindsight.king_of_castrop_rauxel.action.DialogueAction; +import com.hindsight.king_of_castrop_rauxel.event.*; +import lombok.extern.slf4j.Slf4j; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.TypeDescription; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.Constructor; +import org.yaml.snakeyaml.representer.Representer; + +@Slf4j +public class YamlReader { + + protected static final String DIALOGUE_TAG = "!dialogue"; + protected static final String ACTION_TAG = "!action"; + + public EventDto read(String fileName) { + var inputStream = getClass().getClassLoader().getResourceAsStream(fileName); + var options = new LoaderOptions(); + var representer = getRepresenter(); + var constructor = getConstructor(options); + var yaml = new Yaml(constructor, representer); + var data = (EventDto) yaml.load(inputStream); + if (data.eventDetails == null) { + return new EventDto(new EventDetails(), data.participantData); + } + return data; + } + + /** Skip unknown parameters when parsing to a Java object */ + protected Representer getRepresenter() { + var representer = new Representer(new DumperOptions()); + representer.getPropertyUtils().setSkipMissingProperties(true); + return representer; + } + + /** Set custom tags in the Yaml file that ensure parsing to the correct class */ + protected Constructor getConstructor(LoaderOptions options) { + var constructor = new Constructor(EventDto.class, options); + constructor.addTypeDescription(new TypeDescription(Dialogue.class, DIALOGUE_TAG)); + constructor.addTypeDescription(new TypeDescription(DialogueAction.class, ACTION_TAG)); + return constructor; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Bounds.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Bounds.java new file mode 100644 index 0000000..d337f72 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Bounds.java @@ -0,0 +1,22 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class Bounds { + + /** Inclusive lower bounds. */ + private int lower; + + /** Inclusive upper bounds. */ + private int upper; + + @Override + public String toString() { + return lower + "-" + upper; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Chunk.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Chunk.java new file mode 100644 index 0000000..ea958dd --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Chunk.java @@ -0,0 +1,138 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.*; + +import com.hindsight.king_of_castrop_rauxel.graphs.Graph; +import com.hindsight.king_of_castrop_rauxel.location.AbstractLocation; +import com.hindsight.king_of_castrop_rauxel.location.Settlement; +import java.util.Random; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; + +@Slf4j +public class Chunk implements Generatable, Unloadable { + + private final WorldHandler worldHandler; + + @Getter private final String id; + @Getter private final int density; + @Getter private final int[][] plane = new int[CHUNK_SIZE][CHUNK_SIZE]; + @Getter private final Coordinates coordinates; + private final Random random; + private final WorldHandler.Strategy strategy; + + @Getter @Setter private boolean isLoaded; + + public enum LocationType { + EMPTY, + SETTLEMENT + } + + public Chunk( + Pair worldCoords, + WorldHandler worldHandler, + WorldHandler.Strategy strategy) { + var seed = SeedBuilder.seedFrom(worldCoords); + this.coordinates = new Coordinates(worldCoords, Coordinates.CoordType.WORLD); + this.random = new Random(seed); + this.density = ChunkBuilder.randomDensity(random); + this.id = IdBuilder.idFrom(this.getClass(), coordinates); + this.worldHandler = worldHandler; + this.strategy = strategy; + load(); + } + + public Chunk(Pair worldCoords, WorldHandler worldHandler) { + var seed = SeedBuilder.seedFrom(worldCoords); + this.coordinates = new Coordinates(worldCoords, Coordinates.CoordType.WORLD); + this.random = new Random(seed); + this.density = ChunkBuilder.randomDensity(random); + this.id = IdBuilder.idFrom(this.getClass(), coordinates); + this.worldHandler = worldHandler; + this.strategy = WorldHandler.Strategy.DEFAULT; + load(); + } + + @Override + public void load() { + worldHandler.populate(this, strategy); + setLoaded(true); + logResult(); + } + + @Override + public void unload() { + setLoaded(false); + logResult(); + } + + @Override + public void logResult() { + var action = isLoaded ? "Generated" : "Unloaded"; + log.info( + "{}: Chunk '{}' at {} with density {} using seed {}", + action, + id, + coordinates, + density, + SeedBuilder.seedFrom(coordinates.getGlobal())); + } + + public String getSummary() { + return String.format("Chunk '%s' at %s with density %d", id, coordinates, density); + } + + public Pair getCentreCoords() { + return Pair.of(CHUNK_SIZE / 2, CHUNK_SIZE / 2); + } + + public Pair getRandomCoords() { + var x = -1; + var y = -1; + while (!isValidPosition(x, y) || hasNeighbors(x, y, MIN_PLACEMENT_DISTANCE)) { + x = random.nextInt(CHUNK_SIZE + 1); + y = random.nextInt(CHUNK_SIZE + 1); + } + return Pair.of(x, y); + } + + public Settlement getCentralLocation(World world, Graph map) { + var globalCoords = world.getCurrentChunk().getCoordinates().getGlobal(); + var startVertex = worldHandler.closestLocationTo(globalCoords, map.getVertices()); + var centralLocation = startVertex.getLocation(); + if (centralLocation != null) { + log.info("Found central location: {}", centralLocation.getBriefSummary()); + centralLocation.load(); + return (Settlement) centralLocation; + } + throw new IllegalStateException("Could not find any central location"); + } + + public void place(Pair chunkCoords, LocationType type) { + var x = chunkCoords.getFirst(); + var y = chunkCoords.getSecond(); + if (isValidPosition(x, y)) { + plane[x][y] = type.ordinal(); + } + } + + private boolean isValidPosition(int x, int y) { + return x >= 0 && x < CHUNK_SIZE && y >= 0 && y < CHUNK_SIZE; + } + + private boolean hasNeighbors(int x, int y, int distance) { + for (var dx = -distance; dx <= distance; dx++) { + for (var dy = -distance; dy <= distance; dy++) { + var neighborX = x + dx; + var neighborY = y + dy; + if (isValidPosition(neighborX, neighborY) + && plane[neighborX][neighborY] > LocationType.EMPTY.ordinal()) { + return true; + } + } + } + return false; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/world/ChunkBuilder.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/ChunkBuilder.java new file mode 100644 index 0000000..937e4e6 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/ChunkBuilder.java @@ -0,0 +1,67 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.*; +import static com.hindsight.king_of_castrop_rauxel.world.WorldHandler.*; + +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public class ChunkBuilder { + + public static int randomDensity(Random random) { + return random.nextInt(DENSITY.getUpper() - DENSITY.getLower() + 1) + DENSITY.getLower(); + } + + public static boolean isInsideTriggerZone(Pair chunkCoords) { + return chunkCoords.getFirst() > CHUNK_SIZE - GENERATION_TRIGGER_ZONE + || chunkCoords.getFirst() < GENERATION_TRIGGER_ZONE + || chunkCoords.getSecond() > CHUNK_SIZE - GENERATION_TRIGGER_ZONE + || chunkCoords.getSecond() < GENERATION_TRIGGER_ZONE; + } + + // TODO: Expand to include all 8 directions + public static CardinalDirection nextChunkPosition(Pair chunkCoords) { + if (chunkCoords.getFirst() > CHUNK_SIZE - GENERATION_TRIGGER_ZONE) { + return CardinalDirection.EAST; + } else if (chunkCoords.getFirst() < GENERATION_TRIGGER_ZONE) { + return CardinalDirection.WEST; + } else if (chunkCoords.getSecond() > CHUNK_SIZE - GENERATION_TRIGGER_ZONE) { + return CardinalDirection.NORTH; + } else if (chunkCoords.getSecond() < GENERATION_TRIGGER_ZONE) { + return CardinalDirection.SOUTH; + } else { + return CardinalDirection.THIS; + } + } + + public static void log(Chunk chunk, CardinalDirection where) { + if (chunk == null) { + log.info("{} chunk does not exist yet", where); + return; + } + var settlements = new AtomicInteger(); + Arrays.stream(chunk.getPlane()) + .forEach( + row -> { + for (var cell : row) { + if (cell > 0) { + settlements.getAndIncrement(); + } + } + }); + log.info( + "{} chunk at {} has a density of {} and {} settlements", + where, + chunk.getCoordinates(), + chunk.getDensity(), + settlements); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Coordinates.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Coordinates.java new file mode 100644 index 0000000..8e44c39 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Coordinates.java @@ -0,0 +1,169 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.*; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; + +@Slf4j +@Getter +public class Coordinates { + + private Pair global; + private Pair world; + private Pair chunk; + + public enum CoordType { + GLOBAL, + WORLD, + CHUNK + } + + public Coordinates(Pair worldCoords, Pair chunkCoords) { + verify(worldCoords, CoordType.WORLD); + verify(chunkCoords, CoordType.CHUNK); + this.chunk = chunkCoords; + this.world = worldCoords; + this.global = toGlobalCoords(worldCoords, chunkCoords); + } + + public Coordinates(Pair chunkCoords, Chunk chunk) { + verify(chunkCoords, CoordType.CHUNK); + this.chunk = chunkCoords; + this.world = chunk.getCoordinates().getWorld(); + this.global = toGlobalCoords(world, chunkCoords); + } + + public Coordinates(Pair coords, CoordType type) { + verify(coords, type); + switch (type) { + case GLOBAL -> { + this.global = coords; + this.world = toWorldCoords(coords); + this.chunk = toChunkCoords(coords); + } + case WORLD -> { + this.chunk = Pair.of(0, 0); + this.world = coords; + this.global = toGlobalCoords(coords, chunk); + } + case CHUNK -> { + this.chunk = coords; + this.global = toGlobalCoords(Pair.of(0, 0), coords); + this.world = Pair.of(0, 0); + log.error("Do not create 'Coordinates' from chunk coordinates only - refactor your code"); + } + } + } + + public void setTo(Pair globalCoords) { + verify(globalCoords, CoordType.GLOBAL); + this.global = globalCoords; + this.world = toWorldCoords(globalCoords); + this.chunk = toChunkCoords(globalCoords); + } + + /** + * Returns true if the coordinates are within the relevant bounds i.e. CHUNK_SIZE for chunkCoords, + * WORLD_SIZE for worldCoords and CHUNK_SIZE * WORLD_SIZE for globalCoords. + */ + private void verify(Pair anyCoords, CoordType type) { + var max = + switch (type) { + case GLOBAL -> CHUNK_SIZE * WORLD_SIZE; + case WORLD -> WORLD_SIZE; + case CHUNK -> CHUNK_SIZE; + }; + var x = (int) anyCoords.getFirst(); + var y = (int) anyCoords.getSecond(); + if (x >= 0 && x <= max && y >= 0 && y <= max) { + return; + } + throw new IllegalArgumentException( + "%s coordinates %s are out of bounds (%s)".formatted(type, anyCoords, max)); + } + + public boolean equalTo(Pair anyCoords, CoordType type) { + var thisCoords = + switch (type) { + case GLOBAL -> global; + case WORLD -> world; + case CHUNK -> chunk; + }; + return (int) thisCoords.getFirst() == anyCoords.getFirst() + && (int) thisCoords.getSecond() == anyCoords.getSecond(); + } + + public int distanceTo(Coordinates other) { + return distanceTo(other.getGlobal()); + } + + public int distanceTo(Pair globalCoords) { + var deltaX = Math.abs(global.getFirst() - globalCoords.getFirst()); + var deltaY = Math.abs(global.getSecond() - globalCoords.getSecond()); + var distance = Math.sqrt((double) deltaX * deltaX + deltaY * deltaY); + return (int) Math.round(distance); + } + + public int gX() { + return global.getFirst(); + } + + public int gY() { + return global.getSecond(); + } + + public int wX() { + return world.getFirst(); + } + + public int wY() { + return world.getSecond(); + } + + public int cX() { + return chunk.getFirst(); + } + + public int cY() { + return chunk.getSecond(); + } + + private Pair toGlobalCoords( + Pair worldCoords, Pair chunkCoords) { + var x = chunkCoords.getFirst() + (worldCoords.getFirst() * CHUNK_SIZE); + var y = chunkCoords.getSecond() + (worldCoords.getSecond() * CHUNK_SIZE); + return Pair.of(x, y); + } + + private Pair toWorldCoords(Pair globalCoords) { + var x = globalCoords.getFirst() / CHUNK_SIZE; + var y = globalCoords.getSecond() / CHUNK_SIZE; + return Pair.of(x, y); + } + + private Pair toChunkCoords(Pair globalCoords) { + var x = globalCoords.getFirst() % CHUNK_SIZE; + var y = globalCoords.getSecond() % CHUNK_SIZE; + return Pair.of(x, y); + } + + @Override + public String toString() { + return "Coords(" + "g=(" + gX() + ", " + gY() + "), w=(" + wX() + ", " + wY() + "), c=(" + cX() + + ", " + cY() + "))"; + } + + public String globalToString() { + return "g(" + gX() + ", " + gY() + ")"; + } + + public String worldToString() { + return "w(" + wX() + ", " + wY() + ")"; + } + + public String chunkToString() { + return "c(" + cX() + ", " + cY() + ")"; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Generatable.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Generatable.java new file mode 100644 index 0000000..fdd68fa --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Generatable.java @@ -0,0 +1,12 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +public interface Generatable { + + String getId(); + + boolean isLoaded(); + + void load(); + + void logResult(); +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/world/IdBuilder.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/IdBuilder.java new file mode 100644 index 0000000..a8af28f --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/IdBuilder.java @@ -0,0 +1,64 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import java.util.*; + +import lombok.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class IdBuilder { + + private static final Map, String> abbreviations = new HashMap<>(); + private static final String SEPARATOR = "~"; + + public static String idFrom(Class clazz, Coordinates coordinates) { + var threeLetters = getAbbreviationFor(clazz); + return threeLetters + SEPARATOR + coordinates.gX() + coordinates.gY(); + } + + public static String idFrom(Class clazz) { + var threeLetters = getAbbreviationFor(clazz); + var uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 10); + return threeLetters + SEPARATOR + uuid.toUpperCase(); + } + + public static String idFrom(Class clazz, String parentId) { + var threeLetters = getAbbreviationFor(clazz); + var parentSubString = parentId.substring(parentId.indexOf(SEPARATOR) + 1); + return threeLetters + SEPARATOR + parentSubString; + } + + private static String getAbbreviationFor(Class clazz) { + if (abbreviations.containsKey(clazz)) { + return abbreviations.get(clazz); + } + return generateAbbreviationFor(clazz); + } + + private static String generateAbbreviationFor(Class clazz) { + var threeLetters = ""; + var isUnique = false; + var offset = 0; + while (!isUnique) { + threeLetters = getThreeLetters(clazz, offset); + if (!abbreviations.containsValue(threeLetters)) { + abbreviations.put(clazz, threeLetters); + isUnique = true; + } + offset++; + } + return threeLetters; + } + + private static String getThreeLetters(Class clazz, int offset) { + if (clazz.getSimpleName().length() < offset) { + throw new IllegalStateException( + "Could not find unique abbreviation for class %s, failed at offset %s" + .formatted(clazz.getSimpleName(), offset)); + } + return clazz.getSimpleName().substring(offset, offset + 3).toUpperCase(); + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Randomisable.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Randomisable.java new file mode 100644 index 0000000..a8b8ae4 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Randomisable.java @@ -0,0 +1,8 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import java.util.Random; + +public interface Randomisable { + + void load(Random random); +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Range.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Range.java new file mode 100644 index 0000000..d9779a9 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Range.java @@ -0,0 +1,48 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.DUNGEON_TIER_DIVIDER; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.util.Random; + +@Getter +@Setter +@AllArgsConstructor +public class Range { + + /** Base multiplier of the current level upon which the modifier will be applied. */ + private float multiplier; + + /** Inclusive lower bounds. */ + private float minMod; + + /** Exclusive upper bounds. */ + private float maxMod; + + public Bounds toBounds(int level) { + var upper = level * multiplier * maxMod; + var lower = level * multiplier * minMod; + return new Bounds((int) lower, (int) upper); + } + + public int toRandomActual(Random random, int targetLevel) { + var upper = targetLevel * multiplier * maxMod; + var lower = targetLevel * multiplier * minMod; + var randomOffset = random.nextFloat(upper - lower); + return toActual(targetLevel, upper, lower, randomOffset); + } + + private int toActual(int targetLevel, float upper, float lower, float offset) { + var modulus = targetLevel % DUNGEON_TIER_DIVIDER; + var modifier = 1 - (1 / modulus); + return (int) ((upper - lower) * modifier) + (int) (lower + offset); + } + + @Override + public String toString() { + return multiplier + " x " + minMod + "-" + maxMod; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/world/SeedBuilder.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/SeedBuilder.java new file mode 100644 index 0000000..506f24f --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/SeedBuilder.java @@ -0,0 +1,34 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import lombok.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Component; + +import java.util.Random; + +@Slf4j +@Component +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SeedBuilder { + + private static long seed = 1234L; + private static Random random = new Random(seed); + + public static void changeSeed(long seed) { + SeedBuilder.seed = seed; + random = new Random(seed); + } + + public static long seedFrom(Pair coordinates) { + return seed + coordinates.getFirst() + coordinates.getSecond(); + } + + /** + * This instance is used for the generation of nodes and edges. It is not used for the generation + * of settlements, their amenities and names, which is done through a new instance of Random. + */ + public static Random getInstance() { + return random; + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Unloadable.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Unloadable.java new file mode 100644 index 0000000..1e5c714 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/Unloadable.java @@ -0,0 +1,10 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +public interface Unloadable { + + boolean isLoaded(); + + void unload(); + + void logResult(); +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/world/World.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/World.java new file mode 100644 index 0000000..7d2e00a --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/World.java @@ -0,0 +1,205 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.CHUNK_SIZE; +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.RETENTION_ZONE; +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.WORLD_SIZE; + +import com.hindsight.king_of_castrop_rauxel.configuration.AppProperties; +import com.hindsight.king_of_castrop_rauxel.graphs.Graph; +import com.hindsight.king_of_castrop_rauxel.location.AbstractLocation; +import com.hindsight.king_of_castrop_rauxel.world.WorldHandler.CardinalDirection; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; + +@Slf4j +@RequiredArgsConstructor +public class World { + + private final AppProperties appProperties; + private final WorldHandler worldHandler; + + @Getter private Chunk currentChunk; + private final Chunk[][] plane = new Chunk[WORLD_SIZE][WORLD_SIZE]; + + public boolean hasChunk(CardinalDirection where) { + var x = (int) currentChunk.getCoordinates().getWorld().getFirst(); + var y = (int) currentChunk.getCoordinates().getWorld().getSecond(); + return switch (where) { + case THIS -> plane[x][y] != null; + case NORTH -> plane[x][y + 1] != null; + case NORTH_EAST -> plane[x + 1][y + 1] != null; + case EAST -> plane[x + 1][y] != null; + case SOUTH_EAST -> plane[x + 1][y - 1] != null; + case SOUTH -> plane[x][y - 1] != null; + case SOUTH_WEST -> plane[x - 1][y - 1] != null; + case WEST -> plane[x - 1][y] != null; + case NORTH_WEST -> plane[x - 1][y + 1] != null; + }; + } + + public boolean hasChunk(Pair worldCoords) { + return getChunk(worldCoords) != null; + } + + public boolean hasLoadedChunk(CardinalDirection where) { + var chunk = getChunk(where); + return chunk != null && chunk.isLoaded(); + } + + public boolean hasLoadedChunk(Pair worldCoords) { + var chunk = getChunk(worldCoords); + return chunk != null && chunk.isLoaded(); + } + + public Chunk getChunk(CardinalDirection where) { + var x = (int) currentChunk.getCoordinates().getWorld().getFirst(); + var y = (int) currentChunk.getCoordinates().getWorld().getSecond(); + return switch (where) { + case THIS -> plane[x][y]; + case NORTH -> plane[x][y + 1]; + case NORTH_EAST -> plane[x + 1][y + 1]; + case EAST -> plane[x + 1][y]; + case SOUTH_EAST -> plane[x + 1][y - 1]; + case SOUTH -> plane[x][y - 1]; + case SOUTH_WEST -> plane[x - 1][y - 1]; + case WEST -> plane[x - 1][y]; + case NORTH_WEST -> plane[x - 1][y + 1]; + }; + } + + private Chunk getChunk(Pair worldCoords) { + return plane[worldCoords.getFirst()][worldCoords.getSecond()]; + } + + /** Returns the world coordinates of the chunk in the center of the world. */ + public Pair getCentreCoords() { + return Pair.of(WORLD_SIZE / 2, WORLD_SIZE / 2); + } + + /** + * Returns the world coordinates of the chunk in the given position relative to the current chunk. + */ + public Pair getCoordsFor(CardinalDirection where) { + return getCoordsFor(where, currentChunk); + } + + public void generateChunk(Pair worldCoords, Graph map) { + throwErrorIfChunkExists(worldCoords, this); + var stats = worldHandler.getStats(map); + var chunk = new Chunk(worldCoords, worldHandler); + place(chunk); + worldHandler.logOutcome(stats, map, this.getClass()); + } + + public void generateChunk(CardinalDirection where, Graph map) { + throwErrorIfChunkExists(where, this); + var stats = worldHandler.getStats(map); + var nextChunk = new Chunk(getCoordsFor(where), worldHandler); + place(nextChunk, where); + worldHandler.logOutcome(stats, map, this.getClass()); + } + + /** Places a chunk in the center of the world. Used to place the first chunk in a new world. */ + private void place(Chunk chunk) { + var center = getCentreCoords(); + plane[center.getFirst()][center.getSecond()] = chunk; + } + + /** + * Places a chunk in the given position relative to the current chunk. Used to place any + * subsequently created chunks in an existing world. + */ + private void place(Chunk chunk, CardinalDirection where) { + var newCoords = getCoordsFor(where, currentChunk); + if (where == CardinalDirection.THIS) { + log.warn( + "You are placing a chunk in the same position as the current chunk, if it exists - if this is intentional, you must setCurrentChunk first"); + } + plane[newCoords.getFirst()][newCoords.getSecond()] = chunk; + } + + /** + * Places a chunk in the specified position. Mostly used when testing but would be used more + * frequently if more than one player was supported. + */ + public void place(Chunk chunk, Pair worldCoords) { + plane[worldCoords.getFirst()][worldCoords.getSecond()] = chunk; + } + + public void setCurrentChunk(Pair worldCoords) { + var chunk = getChunk(worldCoords); + if (chunk == null) { + throw new IllegalStateException( + "%s cannot be the current chunk because it is null".formatted(worldCoords)); + } + if (!chunk.isLoaded()) { + chunk.load(); + } + currentChunk = chunk; + log.info("Set current chunk to: {}", chunk.getSummary()); + if (appProperties.getAutoUnload().world()) { + unloadChunks(); + } + } + + private void unloadChunks() { + log.info("Unloading chunks outside retention zone..."); + var x = (int) currentChunk.getCoordinates().getWorld().getFirst(); + var y = (int) currentChunk.getCoordinates().getWorld().getSecond(); + for (int i = 0; i < WORLD_SIZE; i++) { + for (int j = 0; j < WORLD_SIZE; j++) { + if (isValidPosition(i, j) + && hasLoadedChunk(Pair.of(i, j)) + && isInsideRemovalZone(i, x, j, y)) { + getChunk(Pair.of(i, j)).unload(); + } + } + } + } + + private static boolean isInsideRemovalZone(int targetX, int currX, int targetY, int currY) { + return Math.abs(targetX - currX) > RETENTION_ZONE || Math.abs(targetY - currY) > RETENTION_ZONE; + } + + private boolean isValidPosition(int x, int y) { + return x >= 0 && x < CHUNK_SIZE && y >= 0 && y < CHUNK_SIZE; + } + + private static Pair getCoordsFor(CardinalDirection where, Chunk chunk) { + var x = (int) chunk.getCoordinates().getWorld().getFirst(); + var y = (int) chunk.getCoordinates().getWorld().getSecond(); + return switch (where) { + case THIS -> Pair.of(x, y); + case NORTH -> Pair.of(x, y + 1); + case NORTH_EAST -> Pair.of(x + 1, y + 1); + case EAST -> Pair.of(x + 1, y); + case SOUTH_EAST -> Pair.of(x + 1, y - 1); + case SOUTH -> Pair.of(x, y - 1); + case SOUTH_WEST -> Pair.of(x - 1, y - 1); + case WEST -> Pair.of(x - 1, y); + case NORTH_WEST -> Pair.of(x - 1, y + 1); + }; + } + + private static void throwErrorIfChunkExists(CardinalDirection where, World world) { + if (world.hasChunk(where)) { + throw new IllegalStateException( + String.format( + "Chunk %s of w(%d, %d) already exists", + where.name().toLowerCase(), + world.getCoordsFor(where).getFirst(), + world.getCoordsFor(where).getSecond())); + } + } + + private static void throwErrorIfChunkExists(Pair worldCoords, World world) { + if (world.hasChunk(worldCoords)) { + throw new IllegalStateException( + String.format( + "Chunk at w(%d, %d) already exists", + worldCoords.getFirst(), worldCoords.getSecond())); + } + } +} diff --git a/src/main/java/com/hindsight/king_of_castrop_rauxel/world/WorldHandler.java b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/WorldHandler.java new file mode 100644 index 0000000..bb476f4 --- /dev/null +++ b/src/main/java/com/hindsight/king_of_castrop_rauxel/world/WorldHandler.java @@ -0,0 +1,240 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.*; +import static com.hindsight.king_of_castrop_rauxel.world.Chunk.*; + +import com.hindsight.king_of_castrop_rauxel.graphs.Graph; +import com.hindsight.king_of_castrop_rauxel.graphs.Vertex; +import com.hindsight.king_of_castrop_rauxel.location.AbstractLocation; +import com.hindsight.king_of_castrop_rauxel.location.Settlement; +import com.hindsight.king_of_castrop_rauxel.utils.DataServices; +import com.hindsight.king_of_castrop_rauxel.utils.Generators; +import java.util.*; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.util.Pair; + +@Slf4j +@RequiredArgsConstructor(onConstructor = @__(@Autowired)) +public class WorldHandler { + + private final Graph map; + private final Generators generators; + private final DataServices dataServices; + + @Getter + public enum CardinalDirection { + THIS("This", 0), + NORTH("North", 1), + NORTH_EAST("North-east", 2), + EAST("East", 3), + SOUTH_EAST("South-east", 4), + SOUTH("South", 5), + SOUTH_WEST("South-west", 6), + WEST("West", 7), + NORTH_WEST("North-west", 8); + + private final String name; + private final int ordinal; + + CardinalDirection(String name, int ordinal) { + this.name = name; + this.ordinal = ordinal; + } + } + + public enum Strategy { + DEFAULT, + NONE, + } + + public void populate(Chunk chunk, Strategy strategy) { + generateSettlements(map, chunk); + if (Strategy.DEFAULT == strategy) { + connectAnyWithinNeighbourDistance(map); + connectNeighbourlessToClosest(map); + connectDisconnectedToClosestConnected(map); + } + } + + protected void generateSettlements(Graph map, Chunk chunk) { + var settlementsCount = chunk.getDensity(); + for (int i = 0; i < settlementsCount; i++) { + var chunkCoords = chunk.getRandomCoords(); + placeSettlement(map, chunk, chunkCoords); + } + } + + private void placeSettlement( + Graph map, Chunk chunk, Pair chunkCoords) { + var worldCoords = chunk.getCoordinates().getWorld(); + var settlement = new Settlement(worldCoords, chunkCoords, generators, dataServices); + map.addVertex(settlement); + chunk.place(chunkCoords, LocationType.SETTLEMENT); + } + + /** Connects any vertices that are within a certain distance of each other. */ + protected void connectAnyWithinNeighbourDistance(Graph map) { + var vertices = map.getVertices(); + for (var reference : vertices) { + for (var other : vertices) { + if (reference.equals(other)) { + continue; + } + var distance = reference.getLocation().distanceTo(other.getLocation()); + if (distance < MAX_GUARANTEED_NEIGHBOUR_DISTANCE) { + addConnections(map, reference, other, distance); + } + } + } + } + + /** + * Connects any vertices that have no neighbours to the closest vertex. Does NOT guarantee that + * all vertices will be connected to the graph. This method will skip any vertex that has been + * connected while running the algorithm even if this vertex has an even closer neighbour. + * Example: A and B are already connected. C's closed neighbour, D, is 100km away. D to A is 10km. + * C will be connected to D and D will be skipped because it now has a neighbour, despite D's + * closest neighbour being A. + */ + protected void connectNeighbourlessToClosest(Graph map) { + var vertices = map.getVertices(); + for (var reference : vertices) { + var refLocation = reference.getLocation(); + var hasNoEdges = map.getVertexByValue(refLocation).getEdges().isEmpty(); + var hasNoNeighbours = refLocation.getNeighbours().isEmpty(); + if ((hasNoEdges && !hasNoNeighbours) || (!hasNoEdges && hasNoNeighbours)) { + throw new IllegalStateException( + String.format( + "Vertex '%s' has %d edges and %d neighbours but both must have the same value", + refLocation.getName(), + refLocation.getNeighbours().size(), + map.getVertexByValue(refLocation).getEdges().size())); + } + if (hasNoNeighbours) { + var closestNeighbour = closestNeighbourTo(reference, vertices); + if (closestNeighbour != null) { + var distance = refLocation.distanceTo(closestNeighbour.getLocation()); + addConnections(map, reference, closestNeighbour, distance); + } + } + } + } + + /** + * Connects any vertices that are not connected to the closest vertex that is connected. This + * method guarantees that all vertices will be connected to the graph. However, it will ignore + * close vertices if they have not been connected to the graph yet. Executing this method prior to + * any other connection algorithm will provide odd results. + */ + protected void connectDisconnectedToClosestConnected(Graph map) { + var connectivity = evaluateConnectivity(map); + var visitedVertices = new ArrayList<>(connectivity.visitedVertices()); + var unvisitedVertices = connectivity.unvisitedVertices(); + if (unvisitedVertices.isEmpty()) { + return; + } + for (var unvisitedVertex : unvisitedVertices) { + var refLocation = unvisitedVertex.getLocation(); + var closestNeighbour = closestNeighbourTo(unvisitedVertex, visitedVertices); + if (closestNeighbour != null) { + var distance = refLocation.distanceTo(closestNeighbour.getLocation()); + addConnections(map, unvisitedVertex, closestNeighbour, distance); + visitedVertices.add(unvisitedVertex); + } + } + } + + protected void addConnections( + Graph map, Vertex vertex1, Vertex vertex2, int distance) { + map.addEdge(vertex1, vertex2, distance); + var v1Location = vertex1.getLocation(); + var v2Location = vertex2.getLocation(); + v1Location.addNeighbour(v2Location); + v2Location.addNeighbour(v1Location); + log.debug( + "Added {} and {} (distance: {} km) as neighbours of each other", + v2Location.getName(), + v1Location.getName(), + distance); + } + + private Vertex closestNeighbourTo( + Vertex reference, List> vertices) { + Vertex closestNeighbor = null; + var minDistance = Integer.MAX_VALUE; + for (var other : vertices) { + if (reference.equals(other)) { + continue; + } + var distance = reference.getLocation().distanceTo(other.getLocation()); + if (distance < minDistance) { + minDistance = distance; + closestNeighbor = other; + } + } + log.debug( + "Closest neighbour of {} is {} (distance: {} km)", + reference.getLocation().getName(), + closestNeighbor != null && closestNeighbor.getLocation() != null + ? closestNeighbor.getLocation().getName() + : "'null'", + minDistance); + return closestNeighbor; + } + + public Vertex closestLocationTo( + Pair globalCoords, List> vertices) { + Vertex closestNeighbor = null; + var minDistance = Integer.MAX_VALUE; + for (var vertex : vertices) { + var distance = vertex.getLocation().getCoordinates().distanceTo(globalCoords); + if (distance < minDistance) { + minDistance = distance; + closestNeighbor = vertex; + } + } + return closestNeighbor; + } + + public void logDisconnectedVertices(Graph graph) { + var result = evaluateConnectivity(graph); + log.info("Unvisited vertices: {}", result.unvisitedVertices().size()); + result.unvisitedVertices().forEach(v -> log.info("- " + v.getLocation().getBriefSummary())); + log.info("Visited vertices: {}", result.visitedVertices().size()); + result.visitedVertices().forEach(v -> log.info("- " + v.getLocation().getBriefSummary())); + } + + protected ConnectivityResult evaluateConnectivity( + Graph graph) { + var visitedVertices = new LinkedHashSet>(); + var unvisitedVertices = new LinkedHashSet<>(graph.getVertices()); + if (!unvisitedVertices.isEmpty()) { + var startVertex = unvisitedVertices.iterator().next(); + Graph.traverseGraphDepthFirst(startVertex, visitedVertices, unvisitedVertices); + } + return new ConnectivityResult<>(visitedVertices, unvisitedVertices); + } + + // TODO: Make this a wrapper within which code can be executed + public void logOutcome(LogStats stats, Graph map, Class clazz) { + log.info("Generation took {} seconds", (System.currentTimeMillis() - stats.startT) / 1000.0); + if (clazz.equals(World.class)) { + log.info("Generated {} settlements", map.getVertices().size() - stats.prevSetCount); + } + } + + public LogStats getStats(Graph map) { + return new LogStats( + System.currentTimeMillis(), + map.getVertices().size(), + map.getVertices().stream().map(Vertex::getLocation).toList()); + } + + public record LogStats(long startT, int prevSetCount, List prevSet) {} + + protected record ConnectivityResult( + Set> visitedVertices, Set> unvisitedVertices) {} +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4b49e8e..d120fdc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,38 @@ -logging.level.root=INFO \ No newline at end of file +spring.profiles.default=cli-dev +spring.datasource.url=jdbc:h2:mem:content_db +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=user +spring.datasource.password=pw +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console +#--- +spring.config.activate.on-profile=cli-dev +logging.level.root=INFO +settings.auto-unload.world=true +settings.environment.use-console-ui=false +settings.environment.clear-console=false +#--- +spring.config.activate.on-profile=cli-debug +logging.level.root=DEBUG +settings.auto-unload.world=false +settings.environment.use-console-ui=true +settings.environment.clear-console=false +#--- +spring.config.activate.on-profile=cli-prod +logging.level.root=OFF +settings.auto-unload.world=true +settings.environment.use-console-ui=true +settings.environment.clear-console=true +#--- +spring.config.activate.on-profile=web-dev +logging.level.root=INFO +settings.auto-unload.world=true +settings.environment.use-console-ui=true +settings.environment.clear-console=false +#--- +spring.config.activate.on-profile=web-prod +logging.level.root=INFO +settings.auto-unload.world=true +settings.environment.use-console-ui=false +settings.environment.clear-console=false \ No newline at end of file diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000..d5953a3 --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,18 @@ + +█ █▀ ▄█ ▄ ▄▀ ████▄ ▄████ +█▄█ ██ █ ▄▀ █ █ █▀ ▀ +█▀▄ ██ ██ █ █ ▀▄ █ █ █▀▀ +█ █ ▐█ █ █ █ █ █ ▀████ █ + █ ▐ █ █ █ ███ █ + ▀ █ ██ ▀ + +▄█▄ ██ ▄▄▄▄▄ ▄▄▄▄▀ █▄▄▄▄ ████▄ █ ▄▄ █▄▄▄▄ ██ ▄ ▄ ▄███▄ █ +█▀ ▀▄ █ █ █ ▀▄ ▀▀▀ █ █ ▄▀ █ █ █ █ █ ▄▀ █ █ █ ▀▄ █ █▀ ▀ █ +█ ▀ █▄▄█ ▄ ▀▀▀▀▄ █ █▀▀▌ █ █ █▀▀▀ █▀▀▌ █▄▄█ █ █ █ ▀ ██▄▄ █ +█▄ ▄▀ █ █ ▀▄▄▄▄▀ █ █ █ ▀████ █ █ █ █ █ █ █ ▄ █ █▄ ▄▀ ███▄ +▀███▀ █ ▀ █ █ █ █ █▄ ▄█ █ ▀▄ ▀███▀ ▀ + █ ▀ ▀ ▀ █ ▀▀▀ ▀ + ▀ ▀ + +GitHub: https://github.com/kimgoetzke/procedural_generation_1 +Loading, please wait... \ No newline at end of file diff --git a/src/main/resources/db/migration/V1__create_items_consumables.sql b/src/main/resources/db/migration/V1__create_items_consumables.sql new file mode 100644 index 0000000..1051322 --- /dev/null +++ b/src/main/resources/db/migration/V1__create_items_consumables.sql @@ -0,0 +1,24 @@ +CREATE TABLE ITEMS_CONSUMABLES +( + id LONG NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + tier INT NOT NULL DEFAULT 1, + base_price INT NOT NULL, + seller_type VARCHAR(50) NOT NULL DEFAULT 'GENERAL', + effect_health INT NOT NULL DEFAULT 0, + effect_max_health INT NOT NULL DEFAULT 0, + PRIMARY KEY (id) +); + +INSERT INTO ITEMS_CONSUMABLES (name, tier, base_price, seller_type, effect_health) +VALUES ('Weak Health Potion', 1, 85, 'GENERAL', 30); +INSERT INTO ITEMS_CONSUMABLES (name, tier, base_price, seller_type, effect_health) +VALUES ('Small Health Potion', 1, 100, 'GENERAL', 60); +INSERT INTO ITEMS_CONSUMABLES (name, tier, base_price, seller_type, effect_health) +VALUES ('Small Health Potion', 1, 100, 'ALCHEMY', 60); +INSERT INTO ITEMS_CONSUMABLES (name, tier, base_price, seller_type, effect_health) +VALUES ('Regular Health Potion', 1, 150, 'ALCHEMY', 90); +INSERT INTO ITEMS_CONSUMABLES (name, tier, base_price, seller_type, effect_health, effect_max_health) +VALUES ('Special House Potion', 1, 250, 'ALCHEMY', 150, 10); +INSERT INTO ITEMS_CONSUMABLES (name, tier, base_price, seller_type, effect_max_health) +VALUES ('Potion of Hope', 1, 1000, 'GENERAL', 50); \ No newline at end of file diff --git a/src/main/resources/events/multi-step/the-obvious-question.yml b/src/main/resources/events/multi-step/the-obvious-question.yml new file mode 100644 index 0000000..5d85071 --- /dev/null +++ b/src/main/resources/events/multi-step/the-obvious-question.yml @@ -0,0 +1,69 @@ +eventDetails: + eventType: DIALOGUE +participantData: + EVENT_GIVER: + - !dialogue + state: AVAILABLE + interactions: + - text: Good day. Haven't seen you here in &L before. You new here? + i: 0 + - text: Well, I guess you're one of 'em travellers, aren't ya? + i: 1 + actions: + - !action + name: Yes, I am - just got here + eventState: NONE + nextInteraction: 2 + - !action + name: No, I live here + eventState: NONE + nextInteraction: 4 + - text: Thought so. Well, I'm &O. Nice to meet you. + i: 2 + - text: I wish I was a traveller. Always wanted to see the world. But I'm stuck here, working me arse off to survive. + i: 3 + actions: + - !action + name: Nice to meet you too, &OF + eventState: ACTIVE + nextInteraction: 0 + - text: You're lying, mate. I can tell. You're not from here. You're a traveller. (&O is visibly getting angry.) + i: 4 + actions: + - !action + name: I'll better leave now - bye! + eventState: DECLINED + playerState: AT_POI + nextInteraction: 0 + - !dialogue + state: ACTIVE + interactions: + - text: Gotta get back to work but if you're around for a while, I'd love to hear about your travels. + actions: + - !action + name: Sure, have a good day! + playerState: AT_POI + - !dialogue + state: READY + interactions: + - text: Gotta get back to work but if you're around for a while, I'd love to hear about your travels. + actions: + - !action + name: Sure, have a good day! + playerState: AT_POI + - !dialogue + state: COMPLETED + interactions: + - text: Gotta get back to work but if you're around for a while, I'd love to hear about your travels. + actions: + - !action + name: Sure, have a good day! + playerState: AT_POI + - !dialogue + state: DECLINED + interactions: + - text: Get outta here, mate! Or I'll bash you're head in! (&O is shaking with anger.) + actions: + - !action + name: (Leave without saying anything) + playerState: AT_POI \ No newline at end of file diff --git a/src/main/resources/events/reach/a-close-friends-parcel.yml b/src/main/resources/events/reach/a-close-friends-parcel.yml new file mode 100644 index 0000000..8d32e19 --- /dev/null +++ b/src/main/resources/events/reach/a-close-friends-parcel.yml @@ -0,0 +1,116 @@ +eventDetails: + eventType: REACH + aboutGiver: a delivery + aboutTarget: the parcel of &O + rewards: + - type: GOLD + minValue: 2 + maxValue: 15 +participantData: + EVENT_GIVER: + - !dialogue + state: AVAILABLE + interactions: + - text: Hey there, hey you! You look like someone who travels a lot. Are heading over to &TI by any chance? If you do, I need you to do something for me. + i: 0 + - text: I need you to get this small parcel to &TO at &TI. It's a bag of seeds and &TOF is a good friend of mine. Can you do it? Please! + i: 1 + actions: + - !action + name: (Accept) Alright, I'll do it + eventState: NONE + nextInteraction: 2 + - !action + name: (Decline) I'm sorry, I can't + eventState: NONE + nextInteraction: 3 + - text: You are amazing - thank you so much! Here, take the parcel. Please, don't lose it! + i: 2 + actions: + - !action + name: Thanks, I'll take good care of it. + eventState: ACTIVE + nextInteraction: 0 + - text: Oh, that's a shame. I really need someone to deliver this parcel to &TOF for me. I'll keep looking for someone else. + i: 3 + actions: + - !action + name: Sorry, I'll have to leave now. + eventState: DECLINED + playerState: AT_POI + nextInteraction: 0 + - !dialogue + state: ACTIVE + interactions: + - text: Really appreciate you doing this for me! Safe travels. + actions: + - !action + name: All good, mate. Goodbye! + playerState: AT_POI + - !dialogue + state: READY + interactions: + - text: Really appreciate you doing this for me! Safe travels. + actions: + - !action + name: All good, mate. Goodbye! + playerState: AT_POI + - !dialogue + state: COMPLETED + interactions: + - text: Thank you so much for delivering the parcel! I really appreciate it. + actions: + - !action + name: All good, mate. Goodbye! + playerState: AT_POI + - !dialogue + state: DECLINED + interactions: + - text: Good news! I have found something else to deliver the parcel for me. + actions: + - !action + name: I'm happy for you and &TOF. Goodbye! + playerState: AT_POI + EVENT_TARGET: + - !dialogue + state: AVAILABLE + interactions: + - text: Hey there, how can I help? + actions: + - !action + name: All good. Thank you. + playerState: AT_POI + - !dialogue + state: ACTIVE + interactions: + - text: This is a parcel from &O, you said? That's marvellous. I've been waiting for this for a while now. Thank you so much for delivering it to me. + - text: Please accept this as a token of my appreciation. (&R) + actions: + - !action + name: Thank you, I appreciate it! + eventState: COMPLETED + playerState: AT_POI + - !dialogue + state: READY + interactions: + - text: Hey there, how can I help? + actions: + - !action + name: All good, mate. Goodbye! + playerState: AT_POI + - !dialogue + state: COMPLETED + interactions: + - text: Thanks again. I'll better get back to it. Have a good day. + actions: + - !action + name: All good, mate. Goodbye! + playerState: AT_POI + - !dialogue + state: DECLINED + interactions: + - text: Hey there, how can I help? + actions: + - !action + name: Goodbye! + playerState: AT_POI diff --git a/src/main/resources/events/reach/urgent-delivery.yml b/src/main/resources/events/reach/urgent-delivery.yml new file mode 100644 index 0000000..c1ffe66 --- /dev/null +++ b/src/main/resources/events/reach/urgent-delivery.yml @@ -0,0 +1,117 @@ +eventDetails: + eventType: REACH + aboutGiver: a delivery + aboutTarget: the delivery from &O + rewards: + - type: GOLD + minValue: 10 + maxValue: 15 +participantData: + EVENT_GIVER: + - !dialogue + state: AVAILABLE + interactions: + - text: Hey, sht! Come over here. I need you to do something for me. + i: 0 + - text: I need you to get this small parcel to &TO at &TI. Don't ask questions and I'll pay you well. You in? + i: 1 + actions: + - !action + name: (Accept) Alright, I'll do it. + eventState: NONE + nextInteraction: 2 + - !action + name: (Decline) I'm sorry, I can't. + eventState: NONE + nextInteraction: 3 + - text: Good, good. Off you go then! + i: 2 + actions: + - !action + name: I'm on my way. + eventState: ACTIVE + nextInteraction: 0 + - text: Well, your loss, friend. &TOF won't be pleased, I'll tell ya that. + i: 3 + actions: + - !action + name: Sorry, I'll have to leave now. + eventState: DECLINED + playerState: AT_POI + nextInteraction: 0 + - !dialogue + state: ACTIVE + interactions: + - text: Please, hurry! Don't just stand around here. + actions: + - !action + name: All good, mate. Goodbye! + playerState: AT_POI + - !dialogue + state: READY + interactions: + - text: Nice one, I heard you delivered the parcel. Here, take this. (&R) + actions: + - !action + name: Thanks. Goodbye! + eventState: COMPLETED + playerState: AT_POI + - !dialogue + state: COMPLETED + interactions: + - text: What? I don't think we've got anything else to talk about. + actions: + - !action + name: Alright. Goodbye! + playerState: AT_POI + - !dialogue + state: DECLINED + interactions: + - text: What? I don't think we've got anything else to talk about. + actions: + - !action + name: Alright. Goodbye! + playerState: AT_POI + EVENT_TARGET: + - !dialogue + state: AVAILABLE + interactions: + - text: Hey there, how can I help? + i: 0 + actions: + - !action + name: All good. Thank you. + playerState: AT_POI + - !dialogue + state: ACTIVE + interactions: + - text: A delivery? From &O? Good. Thanks, now please leave. Go back to &O. + actions: + - !action + name: Alright, bye. + eventState: READY + playerState: AT_POI + - !dialogue + state: READY + interactions: + - text: Hey there, how can I help? + actions: + - !action + name: All good, mate. Goodbye! + playerState: AT_POI + - !dialogue + state: COMPLETED + interactions: + - text: Thanks again. I'll better get back to it. Have a good day. + actions: + - !action + name: All good, mate. Goodbye! + playerState: AT_POI + - !dialogue + state: DECLINED + interactions: + - text: Hey there, how can I help? + actions: + - !action + name: Goodbye! + playerState: AT_POI \ No newline at end of file diff --git a/src/main/resources/events/single-step/npc-dismissive.txt b/src/main/resources/events/single-step/npc-dismissive.txt new file mode 100644 index 0000000..70fc7b6 --- /dev/null +++ b/src/main/resources/events/single-step/npc-dismissive.txt @@ -0,0 +1,27 @@ +I don't have time for whatever this is. +(Looks at you angrily.) What do you want? +(Stares at you blankly) What?! +Hum? +(Looks away.) +(Ignores you.) +(Stares at you but does not speak.) +Can't talk right now. +Can't you see I'm busy? +I don't want to talk to you. +I'm busy. +I'm not interested. +I'm not in the mood. +I'm not talking to you. +I'm not talking to you, so go away. +I'm not talking to you, so leave me alone. +I'm not talking to you, so leave me be. +I'm not talking to you, so leave me in peace. +I'm not talking to you, so leave me. +Leave me be. +Leave me in peace. +Leave me alone. +Move along. +Move along, please. +Move along, please. I'm busy. +Move along, please. I'm not interested. +Go away. \ No newline at end of file diff --git a/src/main/resources/names/Amenity-ENTRANCE.txt b/src/main/resources/names/Amenity-ENTRANCE.txt deleted file mode 100644 index 095968a..0000000 --- a/src/main/resources/names/Amenity-ENTRANCE.txt +++ /dev/null @@ -1,8 +0,0 @@ -Marketplace -City Gates --SQUARE -Northern City Gates -Southern City Gates -Western City Gates -Eastern City Gates -Barricades \ No newline at end of file diff --git a/src/main/resources/names/Amenity-SHOP.txt b/src/main/resources/names/Amenity-SHOP.txt deleted file mode 100644 index cf40266..0000000 --- a/src/main/resources/names/Amenity-SHOP.txt +++ /dev/null @@ -1,8 +0,0 @@ -Metalworking Forge -Textile Workshop -Carpentry Shop -Jewelry Studio -Potter's Workshop -%P's Potter -Armourer -Armourer of %P \ No newline at end of file diff --git a/src/main/resources/names/Amenity-SQUARE.txt b/src/main/resources/names/Amenity-SQUARE.txt deleted file mode 100644 index 26bc6be..0000000 --- a/src/main/resources/names/Amenity-SQUARE.txt +++ /dev/null @@ -1,3 +0,0 @@ -%P Plaza -%P Square -%P Main Square \ No newline at end of file diff --git a/src/main/resources/names/Amenity.txt b/src/main/resources/names/Amenity.txt deleted file mode 100644 index b098d24..0000000 --- a/src/main/resources/names/Amenity.txt +++ /dev/null @@ -1,22 +0,0 @@ -Palace -Royal Residence -Cathedral -Church -Temple -Marketplace -Granaries -Public Bath -Council Hall -City Gates -Public Oven -Guardhouse -City Mint -City Maintenance Depot -Market Authority Building -Record Archive -Tax Collector's Office -Council Chamber -Courthouse -City Hall --SHOP --SQUARE \ No newline at end of file diff --git a/src/main/resources/names/amenity-entrance-s.txt b/src/main/resources/names/amenity-entrance-s.txt new file mode 100644 index 0000000..2637ba7 --- /dev/null +++ b/src/main/resources/names/amenity-entrance-s.txt @@ -0,0 +1,5 @@ +City Gates +Barricades +City Walls +Outer Fence +Barricades \ No newline at end of file diff --git a/src/main/resources/names/amenity-entrance.txt b/src/main/resources/names/amenity-entrance.txt new file mode 100644 index 0000000..9845349 --- /dev/null +++ b/src/main/resources/names/amenity-entrance.txt @@ -0,0 +1,8 @@ +Northern Gates +Southern Gates +Western Gates +Eastern Gates +Northern Barricades +Southern Barricades +Western Barricades +Eastern Barricades \ No newline at end of file diff --git a/src/main/resources/names/amenity-main_square-s.txt b/src/main/resources/names/amenity-main_square-s.txt new file mode 100644 index 0000000..e00c432 --- /dev/null +++ b/src/main/resources/names/amenity-main_square-s.txt @@ -0,0 +1,4 @@ +&P Road +&P Field +Empty Field +Muddy Field \ No newline at end of file diff --git a/src/main/resources/names/amenity-main_square.txt b/src/main/resources/names/amenity-main_square.txt new file mode 100644 index 0000000..5fc8a03 --- /dev/null +++ b/src/main/resources/names/amenity-main_square.txt @@ -0,0 +1,3 @@ +&P Plaza +&P Square +&P Main Square \ No newline at end of file diff --git a/src/main/resources/names/Amenity-QUEST_LOCATION.txt b/src/main/resources/names/amenity-quest_location.txt similarity index 87% rename from src/main/resources/names/Amenity-QUEST_LOCATION.txt rename to src/main/resources/names/amenity-quest_location.txt index 93e75ec..f7fe003 100644 --- a/src/main/resources/names/Amenity-QUEST_LOCATION.txt +++ b/src/main/resources/names/amenity-quest_location.txt @@ -1,4 +1,6 @@ Palace +Palace of &O +&O's Palace Royal Residence Cathedral Church @@ -15,5 +17,4 @@ Record Archive Tax Collector's Office Council Chamber Courthouse -City Hall --SQUARE \ No newline at end of file +City Hall \ No newline at end of file diff --git a/src/main/resources/names/amenity-shop-alchemy.txt b/src/main/resources/names/amenity-shop-alchemy.txt new file mode 100644 index 0000000..1cd49f7 --- /dev/null +++ b/src/main/resources/names/amenity-shop-alchemy.txt @@ -0,0 +1,26 @@ +The Herbal Haven +Potion Emporium +Alchemist's Apothecary +Herbcrafters' Corner +&O's Corner +Elixir Bazaar +&O's Elixirs +Sage's Remedies +&O's Remedies +Mystic Herbs and Potions +&O's Herbs and Potions +Healing Draughts +Enchanted Elixirs +Alchemy Alcove +Botanical Wonders +The Herbalist's Hut +Druid's Delights +The Apothecary's Cache +Magician's Potions +Herbalist's Hideaway +Potions & Poultices +Mystic Herbs Emporium +The Elixir Exchange +Arcane Remedies +The Alchemical Apotheka +Alchemy for &P \ No newline at end of file diff --git a/src/main/resources/names/amenity-shop-general.txt b/src/main/resources/names/amenity-shop-general.txt new file mode 100644 index 0000000..d6b98b8 --- /dev/null +++ b/src/main/resources/names/amenity-shop-general.txt @@ -0,0 +1,28 @@ +&O's General Store +General Store of &O +&P's General Store +General Store of &P +Wares and Goods +Emporium of Wonders +Traders' Haven +Marketplace Treasures +Bazaar Bounties +Merchants' Oasis +Commodities Hub +Treasure Trove Outpost +The Provisions Depot +Trade Winds Emporium +Variety Merchants +Wares of the World +All Goods Emporium +The Commerce Corner +&P's Emporium +&P's Commerce Corner +The Peddler's Plaza +The Curiosity Shop +The Exchange Hub +Merchant's Guild House +&O's Trading Stop +Wagon Traders' Stop +The Stockpile Emporium +Hub of Barter diff --git a/src/main/resources/names/amenity-square.txt b/src/main/resources/names/amenity-square.txt new file mode 100644 index 0000000..5fc8a03 --- /dev/null +++ b/src/main/resources/names/amenity-square.txt @@ -0,0 +1,3 @@ +&P Plaza +&P Square +&P Main Square \ No newline at end of file diff --git a/src/main/resources/names/amenity.txt b/src/main/resources/names/amenity.txt new file mode 100644 index 0000000..29937ee --- /dev/null +++ b/src/main/resources/names/amenity.txt @@ -0,0 +1,86 @@ +THIS FILE IS NOT USED. IT CONTAINS POTENTIAL NAMES FOR FUTURE AMENITIES. + +-- General +Palace +Royal Residence +Cathedral +Church +Temple +Marketplace +Granaries +Public Bath +Council Hall +City Gates +Public Oven +Guardhouse +City Mint +City Maintenance Depot +Market Authority Building +Record Archive +Tax Collector's Office +Council Chamber +Courthouse +City Hall +-SHOP +-SQUARE +WEAPON_SHOP +Tavern +Inn +Blacksmith +Stables +Alchemist +Bakery +Butcher +Farm +Fishmonger +Tailor +Cobbler +Jeweler +Carpenter +Mason +Potter +Weaver +Brewery +Distillery +Winery +Tannery +Leather-worker +Fletcher +Armorer +Saddler +Bookbinder +Bookstore +Library +Scribe +Apothecary +Herbalist +Healer +Physician +Surgeon +Barber +Bathhouse +Baths +Bath +Bathing Room, +University +Academy +School +College +Museum +Art Gallery +Theater + +-- Shops +Metalworking Forge +&O's Forge +Textile Workshop +&O's Textiles +Carpentry Shop +&O's Carpentry +Jewelry Studio +&O's Jewelries +Potter's Workshop +&P's Potter +Armourer +&O's Armour Shop +Armourer of &P \ No newline at end of file diff --git a/src/main/resources/names/basicenemy--type-prefix.txt b/src/main/resources/names/basicenemy--type-prefix.txt new file mode 100644 index 0000000..9a98d5b --- /dev/null +++ b/src/main/resources/names/basicenemy--type-prefix.txt @@ -0,0 +1,79 @@ +Furry +Wild +Small +Large +Heavy +Fat +Awkward +Clumsy +Graceful +Elegant +Slender +Thin +Hungry +Thirsty +Tired +Sleepy +Sick +Healthy +Strong +Weak +Fast +Slow +Quick +Clever +Smart +Intelligent +Wise +Silly +Happy +Sad +Mad +Angry +Calm +Peaceful +Quiet +Noisy +Small +Slender +Thin +Tired +Sleepy +Sick +Weak +Fast +Slow +Silly +Green +Red +Purple +Blue +Pale-blue +Yellow +Orange +Brown +Black +White +Grey +Spotted +Wild +Large +Heavy +Graceful +Elegant +Healthy-looking +Strong +Fast +Quick +Clever +Smart +Intelligent +Wise +Mad +Angry +Furious +Fierce +Ferocious +Cruel +Wicked +Evil \ No newline at end of file diff --git a/src/main/resources/names/dungeon-goblin.txt b/src/main/resources/names/dungeon-goblin.txt new file mode 100644 index 0000000..7ba46b7 --- /dev/null +++ b/src/main/resources/names/dungeon-goblin.txt @@ -0,0 +1,6 @@ +Inner City Grotto +Outer City Grotto +Lair-like Ruins +Hole at the Edge of the City +Den of the Goblins +Grotto Near the City \ No newline at end of file diff --git a/src/main/resources/names/dungeon.txt b/src/main/resources/names/dungeon.txt new file mode 100644 index 0000000..46216d8 --- /dev/null +++ b/src/main/resources/names/dungeon.txt @@ -0,0 +1,11 @@ +Haunted House +Haunted Tavern +Abandoned House +Abandoned Tavern +Abandoned Church +Haunted Church +Haunted Temple +The Haunted Mill +The Haunted Farm +The Haunted Mansion +The Haunted Tower \ No newline at end of file diff --git a/src/main/resources/names/inhabitant--fallback.txt b/src/main/resources/names/inhabitant--fallback.txt new file mode 100644 index 0000000..7e2775f --- /dev/null +++ b/src/main/resources/names/inhabitant--fallback.txt @@ -0,0 +1,4 @@ +A Stranger +Unnamed Stranger +The Outlander +The Nameless \ No newline at end of file diff --git a/src/main/resources/names/inhabitant-first_name.txt b/src/main/resources/names/inhabitant-first_name.txt new file mode 100644 index 0000000..a01df90 --- /dev/null +++ b/src/main/resources/names/inhabitant-first_name.txt @@ -0,0 +1,50 @@ +Akhenaten +Hatshepsut +Ramses +Sesostris +Thutmose +Amenhotep +Nefertiti +Tiy +Merit +Senenmut +Ashurbanipal +Nebuchadnezzar +Hammurabi +Gilgamesh +Enkidu +Sargon +Ashur +Ishtar +Inanna +Shamash +Adad +Nabu +Tukulti-Ninurta +Tiglath-Pileser +Ashur-nasir-pal +Khnumhotep +Senwosret +Sobekhotep +Amenemhat +Thoth +Khafre +Menkaure +Djoser +Ptahhotep +Merneptah +Seti +Merytre +Horemheb +Ay +Ankhesenamun +Tutankhamun +Meritaten +Kiya +Amunet +Marduk +Ea +Nabu +Ishtar +Shamash +Anu \ No newline at end of file diff --git a/src/main/resources/names/inhabitant-last_name.txt b/src/main/resources/names/inhabitant-last_name.txt new file mode 100644 index 0000000..81437c5 --- /dev/null +++ b/src/main/resources/names/inhabitant-last_name.txt @@ -0,0 +1,48 @@ +Suten +Ity +Nakht +Neb +Mery +Sobek +Amun +Iset +Sekhmet +Hapi +Qa'a +Kha +Iunu +Amunet +Tefnut +Taweret +Nefer +Tuya +Nebet +Imhotep +Horem +Ptah +Bak +Aten +Khepri +Akhet +Qetesh +Kadesh +Iah +Khonsu +Anuket +Mafdet +Nekhbet +Reshep +Anat +El +Enlil +Inanna +Ninurta +Ereshkigal +Sin +Isimud +Nanna +Utu +Gula +Ea +Adapa +Ninhursag diff --git a/src/main/resources/names/Settlement--end.txt b/src/main/resources/names/settlement--end.txt similarity index 100% rename from src/main/resources/names/Settlement--end.txt rename to src/main/resources/names/settlement--end.txt diff --git a/src/main/resources/names/Settlement--middle.txt b/src/main/resources/names/settlement--middle.txt similarity index 100% rename from src/main/resources/names/Settlement--middle.txt rename to src/main/resources/names/settlement--middle.txt diff --git a/src/main/resources/names/Settlement--start.txt b/src/main/resources/names/settlement--start.txt similarity index 100% rename from src/main/resources/names/Settlement--start.txt rename to src/main/resources/names/settlement--start.txt diff --git a/src/test/java/com/hindsight/king_of_castrop_rauxel/ApplicationTests.java b/src/test/java/com/hindsight/king_of_castrop_rauxel/ApplicationTests.java index 11e528a..4abf039 100644 --- a/src/test/java/com/hindsight/king_of_castrop_rauxel/ApplicationTests.java +++ b/src/test/java/com/hindsight/king_of_castrop_rauxel/ApplicationTests.java @@ -1,13 +1,16 @@ package com.hindsight.king_of_castrop_rauxel; import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; import org.springframework.boot.test.context.SpringBootTest; +import static org.assertj.core.api.Assertions.assertThat; + @SpringBootTest class ApplicationTests { - @Test - void contextLoads() { - } - + @Test + void contextLoads() { + assertThat(SpringApplication.run(Application.class)).isNotNull(); + } } diff --git a/src/test/java/com/hindsight/king_of_castrop_rauxel/utils/YamlReaderTest.java b/src/test/java/com/hindsight/king_of_castrop_rauxel/utils/YamlReaderTest.java new file mode 100644 index 0000000..8012506 --- /dev/null +++ b/src/test/java/com/hindsight/king_of_castrop_rauxel/utils/YamlReaderTest.java @@ -0,0 +1,101 @@ +package com.hindsight.king_of_castrop_rauxel.utils; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hindsight.king_of_castrop_rauxel.action.Action; +import com.hindsight.king_of_castrop_rauxel.action.DialogueAction; +import com.hindsight.king_of_castrop_rauxel.event.*; +import java.util.*; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.nodes.Tag; + +class YamlReaderTest extends YamlReader { + + private static final int COUNT = 2; + private static final String EXPECTED_TEXT = "EXPECTED_TEXT"; + private static final String EXPECTED_ACTION = + "DialogueAction(index=0, name=EXPECTED_TEXT, eventState=NONE, playerState=null, nextInteraction=2)"; + + /** This test is largely used to generate the expected structure for Yaml files. */ + @Test + void writeObjectToYaml() { + var options = new LoaderOptions(); + var representer = getRepresenter(); + representer.addClassTag(Dialogue.class, new Tag(DIALOGUE_TAG)); + representer.addClassTag(DialogueAction.class, new Tag(ACTION_TAG)); + var constructor = getConstructor(options); + var yaml = new Yaml(constructor, representer, new DumperOptions()); + var eventDto = getEventDto(); + var data = yaml.dumpAsMap(eventDto); + var dialogues = data.split("!dialogue", -1).length - 1; + var actions = data.split("!action", -1).length - 1; + assertThat(data).isNotNull(); + assertThat(dialogues).isEqualTo(12); + assertThat(actions).isEqualTo(48); + } + + @Test + void whenValidYaml_readYamlToEventDto() { + var underTest = new YamlReader(); + var data = underTest.read("yaml-reader-test-file.yml"); + assertThat(data).isNotNull(); + assertThat(data.eventDetails).isNotNull(); + assertThat(data.eventDetails.getEventType()).isEqualTo(Event.Type.REACH); + assertThat(data.eventDetails.getAboutGiver()).isEqualTo(EXPECTED_TEXT); + assertThat(data.eventDetails.getAboutTarget()).isEqualTo(EXPECTED_TEXT); + assertThat(data.eventDetails.getRewards().get(0).getType()).isEqualTo(Reward.Type.GOLD); + assertThat(data.eventDetails.getRewards().get(0).getMinValue()).isEqualTo(10); + assertThat(data.eventDetails.getRewards().get(0).getMaxValue()).isEqualTo(15); + assertThat(data.participantData).isNotNull(); + var giverDialogues = data.participantData.get(Role.EVENT_GIVER); + assertThat(giverDialogues).hasSize(5); + assertThat(data.participantData.get(Role.EVENT_TARGET)).hasSize(5); + var giverInteraction1 = giverDialogues.get(0).getInteractions().get(1); + assertThat(giverDialogues.get(0).getInteractions()).hasSize(4); + assertThat(giverInteraction1.getText()).isEqualTo(EXPECTED_TEXT); + assertThat(giverInteraction1.getActions()).hasSize(2); + assertThat(giverInteraction1.getActions().get(0).getName()).isEqualTo(EXPECTED_TEXT); + assertThat(giverInteraction1.getActions().get(0).toString()).hasToString(EXPECTED_ACTION); + } + + private EventDto getEventDto() { + var eventDetails = new EventDetails(); + var participantsData = new EnumMap>(Role.class); + participantsData.put(Role.EVENT_GIVER, getDialogues()); + participantsData.put(Role.EVENT_TARGET, getDialogues()); + return new EventDto(eventDetails, participantsData); + } + + private List getDialogues() { + var dialogues = new ArrayList(); + for (var state : Event.State.values()) { + var dialogue = new Dialogue(); + dialogue.setState(state); + dialogue.setInteractions(getInteractions()); + dialogues.add(dialogue); + } + return dialogues; + } + + private List getInteractions() { + var interactions = new ArrayList(); + for (int i = 0; i < COUNT; i++) { + var actions = getActions(); + var interaction = new Interaction("Hello %s!".formatted(i), actions); + interactions.add(interaction); + } + return interactions; + } + + private List getActions() { + var actions = new ArrayList(); + for (int j = 0; j < COUNT; j++) { + actions.add(DialogueAction.builder().index(2).name("Say " + j).build()); + } + return actions; + } +} diff --git a/src/test/java/com/hindsight/king_of_castrop_rauxel/world/AutoUnloadingTest.java b/src/test/java/com/hindsight/king_of_castrop_rauxel/world/AutoUnloadingTest.java new file mode 100644 index 0000000..95f8625 --- /dev/null +++ b/src/test/java/com/hindsight/king_of_castrop_rauxel/world/AutoUnloadingTest.java @@ -0,0 +1,103 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.RETENTION_ZONE; +import static com.hindsight.king_of_castrop_rauxel.world.WorldHandler.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mockStatic; + +import com.hindsight.king_of_castrop_rauxel.action.debug.DebugActionFactory; +import com.hindsight.king_of_castrop_rauxel.graphs.Graph; +import com.hindsight.king_of_castrop_rauxel.graphs.Vertex; +import com.hindsight.king_of_castrop_rauxel.location.LocationBuilder; + +import java.util.Random; + +import com.hindsight.king_of_castrop_rauxel.location.Size; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.util.Pair; + +@SpringBootTest +class AutoUnloadingTest extends BaseWorldTest { + + @BeforeEach + void setUp() { + SeedBuilder.changeSeed(123L); + map = new Graph<>(true); + worldHandler = new WorldHandler(map, generators, dataServices); + world = new World(appProperties, worldHandler); + daf = new DebugActionFactory(map, world, worldHandler); + } + + @Test + void whenChangingCurrentChunk_unloadChunksOutsideRetentionZone() { + try (var mocked = mockStatic(LocationBuilder.class)) { + // Given + locationComponentIsInitialised(mocked); + var initialCoords = world.getCentreCoords(); + var removedNorthCoords = Pair.of(initialCoords.getFirst(), initialCoords.getSecond() + 1); + var removedSouthCoords = Pair.of(initialCoords.getFirst(), initialCoords.getSecond() - 1); + + // When + world.generateChunk(initialCoords, map); + world.setCurrentChunk(initialCoords); + for (var i = 0; i < RETENTION_ZONE + 1; i++) { + world.generateChunk(CardinalDirection.NORTH, map); + world.generateChunk(CardinalDirection.EAST, map); + world.generateChunk(CardinalDirection.SOUTH, map); + world.setCurrentChunk(world.getChunk(CardinalDirection.EAST).getCoordinates().getWorld()); + } + debug(map.getVertices(), map); + + // Then + var currentWorldCoords = world.getCurrentChunk().getCoordinates(); + var nCoords = Pair.of(currentWorldCoords.wX() - RETENTION_ZONE, currentWorldCoords.wY() + 1); + var eCoords = Pair.of(currentWorldCoords.wX() - RETENTION_ZONE, currentWorldCoords.wY()); + var sCoords = Pair.of(currentWorldCoords.wX() - RETENTION_ZONE, currentWorldCoords.wY() - 1); + assertThat(world.hasLoadedChunk(removedNorthCoords)).isFalse(); + assertThat(world.hasLoadedChunk(initialCoords)).isFalse(); + assertThat(world.hasLoadedChunk(removedSouthCoords)).isFalse(); + assertThat(world.hasLoadedChunk(nCoords)).isTrue(); + assertThat(world.hasLoadedChunk(eCoords)).isTrue(); + assertThat(world.hasLoadedChunk(sCoords)).isTrue(); + assertThat(world.hasLoadedChunk(CardinalDirection.NORTH_WEST)).isTrue(); + assertThat(world.hasLoadedChunk(CardinalDirection.WEST)).isTrue(); + assertThat(world.hasLoadedChunk(CardinalDirection.SOUTH_WEST)).isTrue(); + } + } + + @Test + void whenReturningToPrevChunk_generateTheSameChunkAgain() { + try (var mocked = mockStatic(LocationBuilder.class)) { + // Given + locationComponentIsInitialised(mocked); + var initialCoords = world.getCentreCoords(); + + // When + world.generateChunk(initialCoords, map); + world.setCurrentChunk(initialCoords); + var expected = map.getVertices().stream().map(Vertex::getLocation).toList(); + for (var i = 0; i < RETENTION_ZONE + 1; i++) { + world.generateChunk(CardinalDirection.EAST, map); + world.setCurrentChunk(world.getChunk(CardinalDirection.EAST).getCoordinates().getWorld()); + } + world.setCurrentChunk(initialCoords); + var result = map.getVertices().stream().map(Vertex::getLocation).toList(); + debug(map.getVertices(), map); + + // Then + assertThat(result).containsAll(expected); + } + } + + @Override + protected void locationComponentIsInitialised(MockedStatic mocked) { + mocked.when(() -> LocationBuilder.randomSize(any(Random.class))).thenReturn(Size.M); + mocked + .when(() -> LocationBuilder.getSettlementConfig(Size.M)) + .thenReturn(fakeConfig.get(Size.M)); + } +} diff --git a/src/test/java/com/hindsight/king_of_castrop_rauxel/world/BaseWorldTest.java b/src/test/java/com/hindsight/king_of_castrop_rauxel/world/BaseWorldTest.java new file mode 100644 index 0000000..90f74f1 --- /dev/null +++ b/src/test/java/com/hindsight/king_of_castrop_rauxel/world/BaseWorldTest.java @@ -0,0 +1,156 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import static com.hindsight.king_of_castrop_rauxel.cli.CliComponent.*; +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.*; +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.XL_AMENITIES_QUEST_LOCATION; +import static com.hindsight.king_of_castrop_rauxel.location.LocationBuilder.*; + +import com.hindsight.king_of_castrop_rauxel.action.debug.DebugActionFactory; +import com.hindsight.king_of_castrop_rauxel.configuration.AppProperties; +import com.hindsight.king_of_castrop_rauxel.graphs.Graph; +import com.hindsight.king_of_castrop_rauxel.graphs.Vertex; +import com.hindsight.king_of_castrop_rauxel.location.AbstractLocation; +import com.hindsight.king_of_castrop_rauxel.location.LocationBuilder; +import com.hindsight.king_of_castrop_rauxel.location.PointOfInterest; +import com.hindsight.king_of_castrop_rauxel.location.Size; +import com.hindsight.king_of_castrop_rauxel.utils.DataServices; +import com.hindsight.king_of_castrop_rauxel.utils.Generators; +import java.util.*; +import org.junit.jupiter.api.AfterEach; +import org.mockito.MockedStatic; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public abstract class BaseWorldTest { + + protected static final Map fakeConfig = new EnumMap<>(Size.class); + + @Autowired protected Generators generators; + @Autowired protected AppProperties appProperties; + @Autowired protected WorldHandler worldHandler; + @Autowired protected DataServices dataServices; + + protected Chunk chunk; + protected World world; + protected Graph map; + protected DebugActionFactory daf; + + public BaseWorldTest() { + generateFakeConfig(); + } + + @AfterEach + void tearDown() { + map = null; + worldHandler = null; + world = null; + daf = null; + } + + protected abstract void locationComponentIsInitialised(MockedStatic mocked); + + protected void debug( + List> vertices, Graph map) { + worldHandler.logDisconnectedVertices(map); + var connectivityResult = worldHandler.evaluateConnectivity(map); + System.out.println("Unvisited vertices: " + connectivityResult.unvisitedVertices().size()); + debugSet(vertices, connectivityResult.unvisitedVertices()); + System.out.println("Visited vertices: " + connectivityResult.visitedVertices().size()); + debugSet(vertices, connectivityResult.visitedVertices()); + try { + daf.printPlane(world, map); + } catch (Exception e) { + System.out.printf( + FMT.RED_BRIGHT + + "Error: Could not print plane - this happens usually because 1) the setUp()/tearDown() does not reset all fields correctly or 2) you never call setCurrentChunk().%n" + + FMT.RESET); + } + daf.printConnectivity(); + } + + protected void debugSet( + List> vertices, Set> vertexSet) { + vertexSet.forEach( + v -> { + System.out.println(v.getLocation().getBriefSummary()); + v.getLocation() + .getNeighbours() + .forEach(n -> System.out.println("- neighbour of: " + n.getName())); + vertices.forEach( + vOther -> + System.out.printf( + "- distance to %s: %s%n", + vOther.getLocation().getName(), + vOther.getLocation().distanceTo(v.getLocation()))); + }); + } + + private void generateFakeConfig() { + SettlementConfig xs = new SettlementConfig(); + SettlementConfig s = new SettlementConfig(); + SettlementConfig m = new SettlementConfig(); + SettlementConfig l = new SettlementConfig(); + SettlementConfig xl = new SettlementConfig(); + + xs.setInhabitants(XS_INHABITANTS); + s.setInhabitants(S_INHABITANTS); + m.setInhabitants(M_INHABITANTS); + l.setInhabitants(L_INHABITANTS); + xl.setInhabitants(XL_INHABITANTS); + + xs.setArea(XS_AREA); + s.setArea(S_AREA); + m.setArea(M_AREA); + l.setArea(L_AREA); + xl.setArea(XL_AREA); + + EnumMap xsAmenities = new EnumMap<>(PointOfInterest.Type.class); + EnumMap sAmenities = new EnumMap<>(PointOfInterest.Type.class); + EnumMap mAmenities = new EnumMap<>(PointOfInterest.Type.class); + EnumMap lAmenities = new EnumMap<>(PointOfInterest.Type.class); + EnumMap xlAmenities = new EnumMap<>(PointOfInterest.Type.class); + + xsAmenities.put(PointOfInterest.Type.ENTRANCE, XS_AMENITIES_ENTRANCE); + sAmenities.put(PointOfInterest.Type.ENTRANCE, S_AMENITIES_ENTRANCE); + mAmenities.put(PointOfInterest.Type.ENTRANCE, M_AMENITIES_ENTRANCE); + lAmenities.put(PointOfInterest.Type.ENTRANCE, L_AMENITIES_ENTRANCE); + xlAmenities.put(PointOfInterest.Type.ENTRANCE, XL_AMENITIES_ENTRANCE); + + xsAmenities.put(PointOfInterest.Type.MAIN_SQUARE, AMENITIES_MAIN_SQUARE); + sAmenities.put(PointOfInterest.Type.MAIN_SQUARE, AMENITIES_MAIN_SQUARE); + mAmenities.put(PointOfInterest.Type.MAIN_SQUARE, AMENITIES_MAIN_SQUARE); + lAmenities.put(PointOfInterest.Type.MAIN_SQUARE, AMENITIES_MAIN_SQUARE); + xlAmenities.put(PointOfInterest.Type.MAIN_SQUARE, AMENITIES_MAIN_SQUARE); + + xsAmenities.put(PointOfInterest.Type.SHOP, XS_AMENITIES_SHOP); + sAmenities.put(PointOfInterest.Type.SHOP, S_AMENITIES_SHOP); + mAmenities.put(PointOfInterest.Type.SHOP, M_AMENITIES_SHOP); + lAmenities.put(PointOfInterest.Type.SHOP, L_AMENITIES_SHOP); + xlAmenities.put(PointOfInterest.Type.SHOP, XL_AMENITIES_SHOP); + + xsAmenities.put(PointOfInterest.Type.QUEST_LOCATION, XS_AMENITIES_QUEST_LOCATION); + sAmenities.put(PointOfInterest.Type.QUEST_LOCATION, S_AMENITIES_QUEST_LOCATION); + mAmenities.put(PointOfInterest.Type.QUEST_LOCATION, M_AMENITIES_QUEST_LOCATION); + lAmenities.put(PointOfInterest.Type.QUEST_LOCATION, L_AMENITIES_QUEST_LOCATION); + xlAmenities.put(PointOfInterest.Type.QUEST_LOCATION, XL_AMENITIES_QUEST_LOCATION); + + xsAmenities.put(PointOfInterest.Type.DUNGEON, XL_AMENITIES_DUNGEON); + sAmenities.put(PointOfInterest.Type.DUNGEON, S_AMENITIES_DUNGEON); + mAmenities.put(PointOfInterest.Type.DUNGEON, M_AMENITIES_DUNGEON); + lAmenities.put(PointOfInterest.Type.DUNGEON, L_AMENITIES_DUNGEON); + xlAmenities.put(PointOfInterest.Type.DUNGEON, XL_AMENITIES_DUNGEON); + + xs.setAmenities(xsAmenities); + s.setAmenities(sAmenities); + m.setAmenities(mAmenities); + l.setAmenities(lAmenities); + xl.setAmenities(xlAmenities); + + fakeConfig.put(Size.XS, xs); + fakeConfig.put(Size.S, s); + fakeConfig.put(Size.M, m); + fakeConfig.put(Size.L, l); + fakeConfig.put(Size.XL, xl); + } +} diff --git a/src/test/java/com/hindsight/king_of_castrop_rauxel/world/BasicTerrainGeneratorTest.java b/src/test/java/com/hindsight/king_of_castrop_rauxel/world/BasicTerrainGeneratorTest.java new file mode 100644 index 0000000..37df402 --- /dev/null +++ b/src/test/java/com/hindsight/king_of_castrop_rauxel/world/BasicTerrainGeneratorTest.java @@ -0,0 +1,53 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.WORLD_CENTER; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Random; + +import com.hindsight.king_of_castrop_rauxel.utils.BasicTerrainGenerator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.util.Pair; + +class BasicTerrainGeneratorTest { + + private BasicTerrainGenerator terrainGenerator; + + @BeforeEach + void setUp() { + terrainGenerator = new BasicTerrainGenerator(); + terrainGenerator.initialise(new Random()); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3, 4, 5, 6, 7}) + void givenDifferentXWorldLocations_returnDifficultyAsExpected(int offset) { + var worldCoords = Pair.of(WORLD_CENTER + offset, WORLD_CENTER); + var chunkCoords = Pair.of(250, 250); + var result = terrainGenerator.getTargetLevel(new Coordinates(worldCoords, chunkCoords)); + var expected = Math.max(offset, 1); + assertThat(result).isEqualTo(expected); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3, 4, 5, 6, 7}) + void givenDifferentYWorldLocations_returnDifficultyAsExpected(int offset) { + var worldCoords = Pair.of(WORLD_CENTER, WORLD_CENTER + offset); + var chunkCoords = Pair.of(250, 250); + var result = terrainGenerator.getTargetLevel(new Coordinates(worldCoords, chunkCoords)); + var expected = Math.max(offset, 1); + assertThat(result).isEqualTo(expected); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3, 4, 5, 6, 7}) + void givenDifferentWorldLocations_returnDifficultyAsExpected(int offset) { + var worldCoords = Pair.of(WORLD_CENTER + offset, WORLD_CENTER + offset); + var chunkCoords = Pair.of(250, 250); + var result = terrainGenerator.getTargetLevel(new Coordinates(worldCoords, chunkCoords)); + var expected = Math.max(offset * 2, 1); + assertThat(result).isEqualTo(expected); + } +} diff --git a/src/test/java/com/hindsight/king_of_castrop_rauxel/world/CoordinatesTest.java b/src/test/java/com/hindsight/king_of_castrop_rauxel/world/CoordinatesTest.java new file mode 100644 index 0000000..15ac16e --- /dev/null +++ b/src/test/java/com/hindsight/king_of_castrop_rauxel/world/CoordinatesTest.java @@ -0,0 +1,162 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import static com.hindsight.king_of_castrop_rauxel.configuration.AppConstants.*; +import static com.hindsight.king_of_castrop_rauxel.world.Coordinates.*; +import static org.junit.jupiter.api.Assertions.*; + +import com.hindsight.king_of_castrop_rauxel.graphs.Graph; +import com.hindsight.king_of_castrop_rauxel.location.AbstractLocation; +import com.hindsight.king_of_castrop_rauxel.utils.DataServices; +import com.hindsight.king_of_castrop_rauxel.utils.Generators; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.util.Pair; + +@SpringBootTest +class CoordinatesTest { + + @Autowired WorldHandler worldHandler; + @Autowired Generators generators; + @Autowired DataServices dataServices; + @Mock Graph map; + + @BeforeEach + void setUp() { + worldHandler = new WorldHandler(map, generators, dataServices); + } + + @Test + void givenWorldAndChunkCoords_createCoords() { + var worldCoords = Pair.of(2, 2); + var chunkCoords = Pair.of(250, 250); + var coordinates = new Coordinates(worldCoords, chunkCoords); + var expectedGlobal = expectedGlobalFrom(worldCoords, chunkCoords); // here (1250, 1250) + + assertEquals(expectedGlobal, coordinates.getGlobal()); + assertEquals(worldCoords, coordinates.getWorld()); + assertEquals(chunkCoords, coordinates.getChunk()); + } + + @Test + void givenChunkCoordsAndChunkInstance_createCoords() { + var worldCoords = Pair.of(8, 6); + var chunkCoords = Pair.of(200, 200); + var expectedGlobal = expectedGlobalFrom(worldCoords, chunkCoords); // here (2700, 3200) + var chunk = new Chunk(worldCoords, worldHandler); + var coordinates = new Coordinates(chunkCoords, chunk); + + assertEquals(expectedGlobal, coordinates.getGlobal()); + assertEquals(worldCoords, coordinates.getWorld()); + assertEquals(chunkCoords, coordinates.getChunk()); + } + + @Test + void givenValidWorldCoords_createCoords() { + var worldCoords = Pair.of(7, 8); + var expectedGlobal = expectedGlobalFrom(worldCoords, Pair.of(0, 0)); // here (3500, 4000) + var coordinates = new Coordinates(worldCoords, CoordType.WORLD); + + assertEquals(expectedGlobal, coordinates.getGlobal()); + assertEquals(worldCoords, coordinates.getWorld()); + assertEquals(Pair.of(0, 0), coordinates.getChunk()); + } + + @Test + void givenOutOfBoundsWorldCoords_failToCreateCoords() { + var worldCoords = Pair.of(-1, 8); + + assertThrows( + IllegalArgumentException.class, () -> new Coordinates(worldCoords, CoordType.WORLD)); + } + + @Test + void givenValidGlobalCoords_createCoords() { + var globalCoords = Pair.of(3205, 675); + var coordinates = new Coordinates(globalCoords, CoordType.GLOBAL); + var expectedWorld = expectedWorldFrom(globalCoords); // here (6, 1) + var expectedChunk = expectedChunkFrom(globalCoords); // here (205, 175) + + assertEquals(globalCoords, coordinates.getGlobal()); + assertEquals(expectedWorld, coordinates.getWorld()); + assertEquals(expectedChunk, coordinates.getChunk()); + } + + @Test + void givenValidGlobalCoords_updateCoords() { + var worldCoords = Pair.of(2, 2); + var chunkCoords = Pair.of(250, 250); + var coordinates = new Coordinates(worldCoords, chunkCoords); + // Creates Coords(g=(1250, 1250), w=(2, 2), c=(250, 250)) + + coordinates.setTo(Pair.of(1230, 1505)); + // Updates to Coords(g=(1230, 1505), w=(2, 3), c=(230, 5)) + + var expected = new Coordinates(Pair.of(1230, 1505), CoordType.GLOBAL); + var x = expected.gX(); + var y = expected.gY(); + + assertEquals(Pair.of(x, y), coordinates.getGlobal()); // here (1230, 1505) + assertEquals(Pair.of(x / CHUNK_SIZE, y / CHUNK_SIZE), coordinates.getWorld()); // here (2, 2) + assertEquals(Pair.of(x % CHUNK_SIZE, y % CHUNK_SIZE), coordinates.getChunk()); // here (230, 5) + } + + @Test + void givenOutOfBoundsGlobalCoords_failToUpdateCoords() { + var coordinates = new Coordinates(Pair.of(2, 3), CoordType.WORLD); + var invalidCoordinates = Pair.of(-1, 14); + + assertThrows(IllegalArgumentException.class, () -> coordinates.setTo(invalidCoordinates)); + } + + @Test + void givenSameWorldCoords_calculateZeroDistance() { + var reference = new Coordinates(Pair.of(2, 3), CoordType.WORLD); + var other = new Coordinates(Pair.of(2, 3), CoordType.WORLD); + var expected = 0; + + assertEquals(expected, reference.distanceTo(other)); + assertEquals(expected, reference.distanceTo(other.getGlobal())); + } + + @Test + void givenDifferentGlobalCoords_calculateDistance1() { + var reference = new Coordinates(Pair.of(1250, 1250), CoordType.GLOBAL); + var other = new Coordinates(Pair.of(1250, 1000), CoordType.GLOBAL); + var expected = 250; + + assertEquals(expected, reference.distanceTo(other)); + assertEquals(expected, reference.distanceTo(other.getGlobal())); + } + + @Test + void givenDifferentGlobalCoords_calculateDistance2() { + var reference = new Coordinates(Pair.of(500, 500), CoordType.GLOBAL); + var other = new Coordinates(Pair.of(1000, 1000), CoordType.GLOBAL); + var expected = 707; + + assertEquals(expected, reference.distanceTo(other)); + assertEquals(expected, reference.distanceTo(other.getGlobal())); + } + + private Pair expectedGlobalFrom( + Pair worldCoords, Pair chunkCoords) { + var x = (worldCoords.getFirst() * CHUNK_SIZE) + chunkCoords.getFirst(); + var y = (worldCoords.getSecond() * CHUNK_SIZE) + chunkCoords.getSecond(); + return Pair.of(x, y); + } + + private Pair expectedWorldFrom(Pair globalCoords) { + var x = globalCoords.getFirst() / CHUNK_SIZE; + var y = globalCoords.getSecond() / CHUNK_SIZE; + return Pair.of(x, y); + } + + private Pair expectedChunkFrom(Pair globalCoords) { + var x = globalCoords.getFirst() % CHUNK_SIZE; + var y = globalCoords.getSecond() % CHUNK_SIZE; + return Pair.of(x, y); + } +} diff --git a/src/test/java/com/hindsight/king_of_castrop_rauxel/world/WorldHandlerTest.java b/src/test/java/com/hindsight/king_of_castrop_rauxel/world/WorldHandlerTest.java new file mode 100644 index 0000000..8ce7ca7 --- /dev/null +++ b/src/test/java/com/hindsight/king_of_castrop_rauxel/world/WorldHandlerTest.java @@ -0,0 +1,222 @@ +package com.hindsight.king_of_castrop_rauxel.world; + +import static com.hindsight.king_of_castrop_rauxel.location.LocationBuilder.randomArea; +import static com.hindsight.king_of_castrop_rauxel.location.LocationBuilder.randomSize; +import static com.hindsight.king_of_castrop_rauxel.world.Coordinates.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mockStatic; + +import com.hindsight.king_of_castrop_rauxel.action.debug.DebugActionFactory; +import com.hindsight.king_of_castrop_rauxel.graphs.Graph; +import com.hindsight.king_of_castrop_rauxel.graphs.Vertex; +import com.hindsight.king_of_castrop_rauxel.location.AbstractLocation; +import com.hindsight.king_of_castrop_rauxel.location.LocationBuilder; +import com.hindsight.king_of_castrop_rauxel.location.Settlement; +import java.util.List; +import java.util.Random; + +import com.hindsight.king_of_castrop_rauxel.location.Size; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.util.Pair; + +@SpringBootTest +class WorldHandlerTest extends BaseWorldTest { + + protected static final Pair C_1_W_COORDS = Pair.of(0, 0); + + @BeforeEach + void setUp() { + SeedBuilder.changeSeed(123L); + world = new World(appProperties, worldHandler); + map = new Graph<>(true); + daf = new DebugActionFactory(map, world, worldHandler); + chunk = new Chunk(C_1_W_COORDS, worldHandler, WorldHandler.Strategy.NONE); + world.place(chunk, C_1_W_COORDS); + } + + @Test + void givenNoConnections_whenEvaluatingConnectivity_findOnlyOneVertex() { + try (var mocked = mockStatic(LocationBuilder.class)) { + // Given + locationComponentIsInitialised(mocked); + chunkWithSettlementsExists(); + + // When + var result = worldHandler.evaluateConnectivity(map); + + // Then + assertThat(result.unvisitedVertices()).hasSize(3); + assertThat(result.visitedVertices()).hasSize(1); + } + } + + @Test + void givenSomeConnections_whenEvaluatingConnectivity_findThemAsExpected() { + try (var mocked = mockStatic(LocationBuilder.class)) { + // Given + locationComponentIsInitialised(mocked); + var vertices = chunkWithSettlementsExists(); + var v1 = vertices.get(0); + var v2 = vertices.get(1); + var v3 = vertices.get(2); + + // When + worldHandler.addConnections(map, v1, v2, v1.getLocation().distanceTo(v2.getLocation())); + worldHandler.addConnections(map, v2, v3, v2.getLocation().distanceTo(v3.getLocation())); + var result = worldHandler.evaluateConnectivity(map); + + // Then + assertThat(result.unvisitedVertices()).hasSize(1); + assertThat(result.unvisitedVertices()).contains(vertices.get(3)); + assertThat(result.visitedVertices()).hasSize(3); + assertThat(v1.getLocation().getNeighbours()).hasSize(1); + assertThat(v2.getLocation().getNeighbours()).hasSize(2); + assertThat(v3.getLocation().getNeighbours()).hasSize(1); + } + } + + @Test + void whenGeneratingSettlements_createThemPredictablyAndDoNotConnectThem() { + try (var mocked = mockStatic(LocationBuilder.class)) { + // Given + locationComponentIsInitialised(mocked); + var expected1 = Pair.of(317, 45); + var expected2 = Pair.of(51, 338); + var expected3 = Pair.of(356, 238); + + // When + worldHandler.generateSettlements(map, chunk); + var vertices = map.getVertices(); + var connectivity = worldHandler.evaluateConnectivity(map); + + // Then + assertEquals(chunk.getDensity(), vertices.size()); + assertEquals(vertices.size() - 1, connectivity.unvisitedVertices().size()); + assertThat(map.getVertexByValue(expected1, CoordType.GLOBAL).getLocation()).isNotNull(); + assertThat(map.getVertexByValue(expected2, CoordType.GLOBAL).getLocation()).isNotNull(); + assertThat(map.getVertexByValue(expected3, CoordType.GLOBAL).getLocation()).isNotNull(); + vertices.forEach(v -> assertThat(v.getLocation().getNeighbours()).isEmpty()); + } + } + + @Test + void whenConnectingAnyWithinNeighbourDistance_connectCloseOnesAsExpected() { + try (var mocked = mockStatic(LocationBuilder.class)) { + // Given + locationComponentIsInitialised(mocked); + + // When + worldHandler.generateSettlements(map, chunk); + worldHandler.connectAnyWithinNeighbourDistance(map); + var BAE = map.getVertexByValue(Pair.of(317, 45), CoordType.GLOBAL).getLocation(); + var VAL = map.getVertexByValue(Pair.of(51, 338), CoordType.GLOBAL).getLocation(); + var AEL = map.getVertexByValue(Pair.of(308, 101), CoordType.GLOBAL).getLocation(); + var AST = map.getVertexByValue(Pair.of(356, 238), CoordType.GLOBAL).getLocation(); + var THE = map.getVertexByValue(Pair.of(220, 61), CoordType.GLOBAL).getLocation(); + var MYS = map.getVertexByValue(Pair.of(191, 399), CoordType.GLOBAL).getLocation(); + var EBR = map.getVertexByValue(Pair.of(84, 468), CoordType.GLOBAL).getLocation(); + + // Then + assertThat(map.getVertices()).hasSize(7); + assertThat(VAL.getNeighbours()).isEmpty(); + assertThat(AST.getNeighbours()).isEmpty(); + assertThat(MYS.getNeighbours()).containsOnly(EBR); + assertThat(EBR.getNeighbours()).containsOnly(MYS); + assertThat(BAE.getNeighbours()).containsOnly(AEL, THE); + assertThat(AEL.getNeighbours()).containsOnly(BAE, THE); + assertThat(THE.getNeighbours()).containsOnly(BAE, AEL); + } + } + + @Test + void whenConnectingNeighbourless_addNeighboursButDoNotConnectAll() { + try (var mocked = mockStatic(LocationBuilder.class)) { + // Given + locationComponentIsInitialised(mocked); + + // When + worldHandler.generateSettlements(map, chunk); + worldHandler.connectNeighbourlessToClosest(map); + var result = worldHandler.evaluateConnectivity(map); + var BAE = map.getVertexByValue(Pair.of(317, 45), CoordType.GLOBAL).getLocation(); + var VAL = map.getVertexByValue(Pair.of(51, 338), CoordType.GLOBAL).getLocation(); + var AEL = map.getVertexByValue(Pair.of(308, 101), CoordType.GLOBAL).getLocation(); + var AST = map.getVertexByValue(Pair.of(356, 238), CoordType.GLOBAL).getLocation(); + var THE = map.getVertexByValue(Pair.of(220, 61), CoordType.GLOBAL).getLocation(); + var MYS = map.getVertexByValue(Pair.of(191, 399), CoordType.GLOBAL).getLocation(); + var EBR = map.getVertexByValue(Pair.of(84, 468), CoordType.GLOBAL).getLocation(); + + // Then + map.getVertices().forEach(v -> assertThat(v.getLocation().getNeighbours()).isNotEmpty()); + assertThat(VAL.getNeighbours()).containsOnly(EBR); + assertThat(MYS.getNeighbours()).containsOnly(EBR); + assertThat(EBR.getNeighbours()).containsOnly(VAL, MYS); + assertThat(BAE.getNeighbours()).containsOnly(AEL); + assertThat(AEL.getNeighbours()).containsOnly(THE, BAE, AST); + assertThat(THE.getNeighbours()).containsOnly(AEL); + assertThat(result.unvisitedVertices()).hasSize(3); + assertThat(result.visitedVertices()).hasSize(4); + } + } + + @Test + void whenConnectingDisconnected_connectAllAsExpected() { + try (var mocked = mockStatic(LocationBuilder.class)) { + // Given + locationComponentIsInitialised(mocked); + + // When + worldHandler.generateSettlements(map, chunk); + worldHandler.connectDisconnectedToClosestConnected(map); + var connectivity = worldHandler.evaluateConnectivity(map); + var BAE = map.getVertexByValue(Pair.of(317, 45), CoordType.GLOBAL).getLocation(); + var VAL = map.getVertexByValue(Pair.of(51, 338), CoordType.GLOBAL).getLocation(); + var AEL = map.getVertexByValue(Pair.of(308, 101), CoordType.GLOBAL).getLocation(); + var AST = map.getVertexByValue(Pair.of(356, 238), CoordType.GLOBAL).getLocation(); + var THE = map.getVertexByValue(Pair.of(220, 61), CoordType.GLOBAL).getLocation(); + var MYS = map.getVertexByValue(Pair.of(191, 399), CoordType.GLOBAL).getLocation(); + var EBR = map.getVertexByValue(Pair.of(84, 468), CoordType.GLOBAL).getLocation(); + + // Then + assertThat(map.getVertices()).hasSize(7); + assertThat(BAE.getNeighbours()).containsOnly(VAL, AEL); + assertThat(VAL.getNeighbours()).containsOnly(MYS, BAE); + assertThat(MYS.getNeighbours()).containsOnly(VAL, EBR); + assertThat(EBR.getNeighbours()).containsOnly(MYS); + assertThat(AEL.getNeighbours()).containsOnly(THE, BAE, AST); + assertThat(AST.getNeighbours()).containsOnly(AEL); + assertThat(THE.getNeighbours()).containsOnly(AEL); + assertThat(connectivity.visitedVertices()).hasSize(7); + } + } + + @Override + protected void locationComponentIsInitialised(MockedStatic mocked) { + mocked.when(() -> randomSize(any(Random.class))).thenReturn(Size.XS); + mocked.when(() -> randomArea(any(Random.class), any())).thenReturn(1); + } + + private List> chunkWithSettlementsExists() { + assertEquals(Size.XS, LocationBuilder.randomSize(new Random())); + assertEquals(1, LocationBuilder.randomArea(new Random(), Size.XS)); + + var v1 = map.addVertex(new Settlement(C_1_W_COORDS, Pair.of(0, 0), generators, dataServices)); + var v2 = map.addVertex(new Settlement(C_1_W_COORDS, Pair.of(20, 20), generators, dataServices)); + var v3 = + map.addVertex(new Settlement(C_1_W_COORDS, Pair.of(100, 100), generators, dataServices)); + var v4 = + map.addVertex(new Settlement(C_1_W_COORDS, Pair.of(500, 500), generators, dataServices)); + + chunk.place(v1.getLocation().getCoordinates().getChunk(), Chunk.LocationType.SETTLEMENT); + chunk.place(v2.getLocation().getCoordinates().getChunk(), Chunk.LocationType.SETTLEMENT); + chunk.place(v3.getLocation().getCoordinates().getChunk(), Chunk.LocationType.SETTLEMENT); + chunk.place(v4.getLocation().getCoordinates().getChunk(), Chunk.LocationType.SETTLEMENT); + + return List.of(v1, v2, v3, v4); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..4671e70 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,5 @@ +spring.profiles.active=cli-dev +logging.level.root=INFO +settings.auto-unload.world=true +settings.environment.clear-console=false +settings.environment.use-console-ui=false \ No newline at end of file diff --git a/src/test/resources/yaml-reader-test-file.yml b/src/test/resources/yaml-reader-test-file.yml new file mode 100644 index 0000000..e8d806b --- /dev/null +++ b/src/test/resources/yaml-reader-test-file.yml @@ -0,0 +1,117 @@ +eventDetails: + eventType: REACH + aboutGiver: EXPECTED_TEXT + aboutTarget: EXPECTED_TEXT + rewards: + - type: GOLD + minValue: 10 + maxValue: 15 +participantData: + EVENT_GIVER: + - !dialogue + state: AVAILABLE + interactions: + - text: Hey, sht! Come over here. I need you to do something for me. + i: 0 + - text: EXPECTED_TEXT + i: 1 + actions: + - !action + name: EXPECTED_TEXT + eventState: NONE + nextInteraction: 2 + - !action + name: (Decline) I'm sorry, I can't. + eventState: NONE + nextInteraction: 3 + - text: Good, good. Off you go then! + i: 2 + actions: + - !action + name: I'm on my way. + eventState: ACTIVE + nextInteraction: 0 + - text: Well, your loss, friend. &TOF won't be pleased, I'll tell ya that. + i: 3 + actions: + - !action + name: Sorry, I'll have to leave now. + eventState: DECLINED + playerState: AT_POI + nextInteraction: 0 + - !dialogue + state: ACTIVE + interactions: + - text: Please, hurry! Don't just stand around here. + actions: + - !action + name: All good, mate. Goodbye! + playerState: AT_POI + - !dialogue + state: READY + interactions: + - text: Nice one, I heard you delivered the parcel. Here, take this. (&R) + actions: + - !action + name: Thanks. Goodbye! + eventState: COMPLETED + playerState: AT_POI + - !dialogue + state: COMPLETED + interactions: + - text: What? I don't think we've got anything else to talk about. + actions: + - !action + name: Alright. Goodbye! + playerState: AT_POI + - !dialogue + state: DECLINED + interactions: + - text: What? I don't think we've got anything else to talk about. + actions: + - !action + name: Alright. Goodbye! + playerState: AT_POI + EVENT_TARGET: + - !dialogue + state: AVAILABLE + interactions: + - text: Hey there, how can I help? + i: 0 + actions: + - !action + name: All good. Thank you. + playerState: AT_POI + - !dialogue + state: ACTIVE + interactions: + - text: A delivery? From &O? Good. Thanks, now please leave. Go back to &O. + actions: + - !action + name: Alright, bye. + eventState: READY + playerState: AT_POI + - !dialogue + state: READY + interactions: + - text: Hey there, how can I help? + actions: + - !action + name: All good, mate. Goodbye! + playerState: AT_POI + - !dialogue + state: COMPLETED + interactions: + - text: Thanks again. I'll better get back to it. Have a good day. + actions: + - !action + name: All good, mate. Goodbye! + playerState: AT_POI + - !dialogue + state: DECLINED + interactions: + - text: Hey there, how can I help? + actions: + - !action + name: Goodbye! + playerState: AT_POI \ No newline at end of file