Compare commits

...

23 Commits

Author SHA1 Message Date
fed9f8d077 fix: unify death handling and add it to all cases of damage taking 2021-08-11 09:30:01 +02:00
cf035dedac fix: correctly set maximum values when parsing entities 2021-08-11 00:31:49 +02:00
a28741ac3d fix: deprecate unsafe gamestate getter 2021-08-11 00:24:34 +02:00
12f4d7592b fix: remove last use of value field in use stone requests 2021-08-10 20:34:10 +02:00
d2942341e2 fix: add missing target existence check to mind stone 2021-08-10 20:26:35 +02:00
673ba9f89d fix: move away from targetEntity in use stone requests in request executing too 2021-08-10 19:21:44 +02:00
a9acbda343 fix: add more checks, fix power stone check and usestone not having a target entity field 2021-08-10 13:00:01 +02:00
6e249280c1 refactor: made stat init nicer 2021-08-10 12:55:20 +02:00
ebd7d2a12b fix: add missing check to reality stone 2021-08-10 12:35:08 +02:00
9f5303ba74 feat: added copy constructor to stat 2021-08-08 19:31:40 +02:00
48409b1e4a fix: mind stone is apparently just a glorified ranged attack 2021-08-07 14:42:19 +02:00
fbe38c978a doc: added TODO comment for scaling vectors 2021-08-07 14:41:29 +02:00
6ab818b155 test: added test for some IntVector2 properties 2021-08-07 14:41:11 +02:00
77be059d45 build: added fancy test logging 2021-08-06 18:23:07 +02:00
ddd0f2f953 test: fixed wrong expected value in MessageValidationUtilityTest 2021-08-06 17:41:40 +02:00
dd8e9813fa fix: only allow space stone on fully free fields 2021-08-06 15:31:32 +02:00
e68298781b fix: revert removing infinity stones from space stone teleportation as this is not possible to happen 2021-08-06 15:26:54 +02:00
d69376c110 fix: remove infinity stones from the map on space stone teleports 2021-08-06 12:45:40 +02:00
315c7d2891 fix: use correct power stone self damage calculation to not kill the character 2021-08-06 12:45:06 +02:00
Richard Reiber
57d384e98c test: made MessageValidation Test work on German computers 2021-08-05 23:41:43 +02:00
df8acbeefa build: added jacoco coverage support 2021-08-05 23:13:56 +02:00
adb0a49711 feat: make rocks attackable 2021-08-05 12:16:29 +02:00
cff18b088e build: updated libraries to newest versions 2021-08-03 20:11:10 +02:00
9 changed files with 390 additions and 78 deletions

View File

@ -2,6 +2,7 @@ plugins {
id 'java'
id 'idea'
id "org.sonarqube" version "3.2.0"
id 'jacoco'
}
repositories {
@ -17,10 +18,76 @@ test {
maxParallelForks = 1
}
// Fancy live test output (from https://stackoverflow.com/questions/3963708/gradle-how-to-display-test-results-in-the-console-in-real-time)
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.api.tasks.testing.logging.TestLogEvent
tasks.withType(Test) {
testLogging {
// set options for log level LIFECYCLE
events TestLogEvent.FAILED,
TestLogEvent.PASSED,
TestLogEvent.SKIPPED
// TestLogEvent.STANDARD_OUT
exceptionFormat TestExceptionFormat.FULL
showExceptions true
showCauses true
showStackTraces true
// set options for log level DEBUG and INFO
debug {
events TestLogEvent.STARTED,
TestLogEvent.FAILED,
TestLogEvent.PASSED,
TestLogEvent.SKIPPED,
TestLogEvent.STANDARD_ERROR
TestLogEvent.STANDARD_OUT
exceptionFormat TestExceptionFormat.FULL
}
// info.events = debug.events
// info.exceptionFormat = debug.exceptionFormat
afterSuite { desc, result ->
if (!desc.parent) { // will match the outermost suite
def output = "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)"
def startItem = '| ', endItem = ' |'
def repeatLength = startItem.length() + output.length() + endItem.length()
println('\n' + ('-' * repeatLength) + '\n' + startItem + output + endItem + '\n' + ('-' * repeatLength))
}
}
}
}
jacocoTestReport {
reports {
html.enabled true
xml.enabled true
xml.destination file("${buildDir}/reports/jacoco.xml")
}
}
plugins.withType(JacocoPlugin) {
tasks["test"].finalizedBy 'jacocoTestReport'
}
sonarqube {
properties {
property "sonar.java.coveragePlugin", "jacoco"
property "sonar.host.url", "https://sonarqube.yandrik.dev"
property "sonar.coverage.jacoco.xmlReportPath", "${buildDir}/reports/jacoco.xml"
}
}
dependencies {
implementation "com.fasterxml.jackson.core:jackson-core:2.12.3"
implementation "com.fasterxml.jackson.core:jackson-annotations:2.12.3"
implementation "com.fasterxml.jackson.core:jackson-databind:2.12.3"
implementation 'com.fasterxml.jackson.core:jackson-core:2.12.4'
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.12.4'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.4'
implementation "org.hibernate.validator:hibernate-validator:7.0.1.Final"
implementation "org.glassfish:jakarta.el:4.0.1"
@ -28,10 +95,10 @@ dependencies {
implementation "org.tinylog:tinylog-api:2.4.0-M1"
implementation "org.tinylog:tinylog-impl:2.4.0-M1"
testImplementation "net.jqwik:jqwik:1.5.1"
testImplementation 'net.jqwik:jqwik:1.5.3'
testImplementation "org.junit.jupiter:junit-jupiter-api:5.8.0-M1"
testImplementation "org.mockito:mockito-core:3.+"
testImplementation "org.assertj:assertj-core:3.19.0"
testImplementation 'org.mockito:mockito-core:3.11.2'
testImplementation 'org.assertj:assertj-core:3.20.2'
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.8.0-M1"
}

View File

@ -110,6 +110,7 @@ public class IntVector2 implements Serializable {
return dot(v.x, v.y);
}
// TODO: This returns a new Vector for now, but should scale the actually modified vector
public IntVector2 scale(float x, float y) {
return new IntVector2(Math.round(this.x * x), Math.round(this.y * y));
}

View File

@ -58,6 +58,37 @@ public class Character extends Entity {
this.meleeDamage = meleeDamage;
}
/**
* Constructs a new {@link Character} with an empty inventory.
* @param id The {@link EntityID} of the character
* @param position The position of the character
* @param name The name of the character
* @param maxHp The maximum hp of the character
* @param maxMp The maximum mp of the character
* @param maxAp The maximum ap of the character
* @param hp The current hp of the character
* @param mp The current mp of the character
* @param ap The current ap of the character
* @param attackRange The ranged attack range of the character
* @param rangedDamage The ranged damage of the character
* @param meleeDamage The melee damage of the character
*/
public Character(EntityID id, IntVector2 position, String name, int maxHp, int maxMp, int maxAp, int hp, int mp, int ap, int attackRange, int rangedDamage, int meleeDamage) {
super(id, position);
solid = false;
opaque = true;
if(id.type == EntityType.NPC && id.id == NPCType.Thanos.getID()) {
solid = true; //characters cannot walk into thanos
}
this.name = name;
this.hp = new Stat(StatType.HP, hp, maxHp);
this.mp = new Stat(StatType.MP, mp, maxMp);
this.ap = new Stat(StatType.AP, ap, maxAp);
this.attackRange = attackRange;
this.rangedDamage = rangedDamage;
this.meleeDamage = meleeDamage;
}
/**
* Checks if the character is still alive.
* @return Whether or not the characters hp is greater than 0

View File

@ -32,8 +32,17 @@ public class Stat {
*/
public Stat(StatType type, int value, int max) {
this.type = type;
this.max = max;
this.value = value;
this.max = max;
}
/**
* Constructs a new {@link Stat} with the same values as the
* given {@link Stat}
* @param toCopy is the {@link Stat} to copy
*/
public Stat(Stat toCopy) {
this(toCopy.type, toCopy.getValue(), toCopy.getMax());
}
public int getValue() {

View File

@ -119,6 +119,7 @@ public class GameInstance {
manager.applyEvent(gameStateEvent);
}
@Deprecated
public GameState getGameStateUnsafe() {
return _state;
}

View File

@ -27,7 +27,7 @@ public class GameLogic {
* Checks a {@link Request} for validity for a {@link GameState}.
* @param state The game state to check on
* @param request The request to validate
* @return Whether or not the request is valid
* @return Whether the request is valid
*/
protected static boolean checkRequest(GameState state, Request request) {
try {
@ -36,15 +36,20 @@ public class GameLogic {
CharacterRequest data = (CharacterRequest)request;
Character origin = getCharacter(state, data.originField, data.originEntity);
Character target = getCharacter(state, data.targetField, data.targetEntity);
Entity target = getAttackable(state, data.targetField, data.targetEntity);
requireTurn(state, origin);
requireOppositeTeam(origin, target);
requireAlive(origin);
requireAlive(target);
requireAP(origin, 1);
if(target instanceof Character) {
Character targetCharacter = (Character)target;
requireOppositeTeam(origin, targetCharacter);
requireAlive(targetCharacter);
}
if(request.type == RequestType.MeleeAttackRequest) {
if(origin.meleeDamage != data.value) {
throw new InvalidRequestException("Invalid melee damage");
@ -126,32 +131,57 @@ public class GameLogic {
case SpaceStone -> {
verifyCoordinates(state, data.targetField);
if(state.entities.blocksMovement(data.targetField)) {
throw new InvalidRequestException("Using space stone onto blocked field");
if(state.entities.findByPosition(data.targetField).size() != 0) {
throw new InvalidRequestException("Using space stone onto non-free field");
}
}
case MindStone -> {
if(data.originField == data.targetField) {
throw new InvalidRequestException("Invalid mind stone target field");
}
if(data.value != state.partyConfig.mindStoneDMG) {
throw new InvalidRequestException("Invalid mind stone damage");
}
verifyCoordinates(state, data.targetField);
requireLineOfSight(state, data.originField, data.targetField);
getAttackableWithoutID(state, data.targetField);
}
case RealityStone -> {
// no check done
verifyCoordinates(state, data.targetField);
boolean rock = false;
boolean empty = true;
for(Entity entity: state.entities.findByPosition(data.targetField)) {
if(entity.id.type == EntityType.Rocks) {
rock = true;
break;
}else {
empty = false;
}
}
if(!empty && !rock) {
throw new InvalidRequestException("Using reality stone on non-free field without a rock");
}
}
case PowerStone -> {
Character target = getCharacter(state, data.targetField, data.targetEntity);
verifyCoordinates(state, data.targetField);
requireAlive(target);
requireOppositeTeam(origin, target);
Entity target = getAttackableWithoutID(state, data.targetField);
if(target instanceof Character) {
Character targetCharacter = (Character)target;
requireOppositeTeam(origin, targetCharacter);
requireAlive(targetCharacter);
}
if(data.originField.distanceChebyshev(data.targetField) != 1) {
throw new InvalidRequestException("Invalid melee target distance");
}
}
case TimeStone -> {
// "👍 i approve" - the server
}
case SoulStone -> {
Character target = getCharacter(state, data.targetField, data.targetEntity);
verifyCoordinates(state, data.targetField);
Character target = getCharacterWithoutID(state, data.targetField);
if(data.originEntity.equals(data.targetEntity)) {
throw new InvalidRequestException("Invalid soul stone target (same as origin)");
@ -183,6 +213,37 @@ public class GameLogic {
return false;
}
/**
* Retrieves an attack-able Entity ({@link Character} or {@link Rock}) for a {@link Request}.
* @param state The game state to use
* @param position The requested position
* @param entityID The requested {@link EntityID}
* @return The found entity
* @throws InvalidRequestException if the entity is invalid or not found
*/
private static Entity getAttackable(GameState state, IntVector2 position, EntityID entityID) throws InvalidRequestException {
Entity entity = state.entities.findEntity(entityID);
if(entity == null || !entity.getPosition().equals(position) || (!(entity instanceof Character) && !(entity instanceof Rock)) || entity.id.type == EntityType.NPC) {
throw new InvalidRequestException("Invalid target character or rock");
}
return entity;
}
/**
* Retrieves an attack-able Entity ({@link Character} or {@link Rock}) for a {@link Request}.
* @param state The game state to use
* @param position The requested position
* @return The found entity
* @throws InvalidRequestException if the entity is invalid or not found
*/
private static Entity getAttackableWithoutID(GameState state, IntVector2 position) throws InvalidRequestException {
ArrayList<Entity> entities = state.entities.findByPosition(position);
if(entities.isEmpty() || entities.get(0).id.type == EntityType.NPC) {
throw new InvalidRequestException("Invalid target character or rock");
}
return entities.get(0);
}
/**
* Retrieves a {@link Character} for a {@link Request}.
* @param state The game state to use
@ -203,6 +264,25 @@ public class GameLogic {
}
}
/**
* Retrieves a {@link Character} for a {@link Request}.
* @param state The game state to use
* @param position The requested position
* @return The found character
* @throws InvalidRequestException if the character is invalid or not found
*/
private static Character getCharacterWithoutID(GameState state, IntVector2 position) throws InvalidRequestException {
ArrayList<Entity> entities = state.entities.findByPosition(position);
if(entities.isEmpty() || !(entities.get(0) instanceof Character) || entities.get(0).id.type == EntityType.NPC) {
throw new InvalidRequestException("Invalid origin or target character");
}
try {
return (Character)entities.get(0);
}catch(Exception ignored) {
throw new InvalidRequestException("Invalid origin or target character (cast failed)");
}
}
/**
* Verifies that a {@link Character} has a turn.
*/
@ -315,26 +395,8 @@ public class GameLogic {
.withAmount(data.value)
.buildEntityEvent());
Character target = (Character)state.entities.findEntity(data.targetEntity);
if(target.hp.getValue() <= data.value) {
List<StoneType> stones = Arrays.asList(target.inventory.getStonesAsArray());
Collections.shuffle(stones); // required by documents
ArrayList<IntVector2> used = new ArrayList<>();
for(StoneType stone: stones) {
ArrayList<IntVector2> options = getFreeNeighbour(state, target.getPosition(), used);
IntVector2 picked = options.get(rand.nextInt(options.size()));
used.add(picked);
result.add(new EventBuilder(EventType.SpawnEntityEvent)
.withEntity(new InfinityStone(
new EntityID(EntityType.InfinityStones, stone.getID()),
picked,
stone
))
.buildEntityEvent());
}
}
Entity targetEntity = state.entities.findEntity(data.targetEntity);
result.addAll(checkDeath(state, targetEntity, data.value));
}
case MoveRequest -> {
CharacterRequest data = (CharacterRequest)request;
@ -422,18 +484,16 @@ public class GameLogic {
.buildCharacterEvent());
}
case MindStone -> {
EntityType target = data.originEntity.type == EntityType.P1 ? EntityType.P2 : EntityType.P1;
for(IntVector2 pos: rasterizeInfinity(data.originField, data.targetField, state.mapSize, false)) {
for(Entity entity: state.entities.findByPosition(pos)) {
if(entity.id.isSameType(target)) {
ArrayList<Entity> found = state.entities.findByPosition(data.targetField);
Entity targetEntity = found.get(0);
result.add(new EventBuilder(EventType.TakenDamageEvent)
.withTargetEntity(entity.id)
.withTargetField(pos)
.withAmount(data.value)
.withTargetEntity(targetEntity.id)
.withTargetField(data.targetField)
.withAmount(state.partyConfig.mindStoneDMG)
.buildEntityEvent());
}
}
}
result.addAll(checkDeath(state, targetEntity, state.partyConfig.mindStoneDMG));
}
case RealityStone -> {
EntityID target = null;
@ -456,20 +516,29 @@ public class GameLogic {
}
}
case PowerStone -> {
ArrayList<Entity> found = state.entities.findByPosition(data.targetField);
Entity targetEntity = found.get(0);
Character origin = (Character)state.entities.findEntity(data.originEntity);
int dmg = (int)Math.round(origin.hp.getMax() * 0.1);
if(origin.hp.getValue() != 1 && dmg > 0) {
//this is ugly ... but also easy to understand
int hp1 = origin.hp.getValue();
int hp2 = Math.max(1, origin.hp.getValue() - dmg);
int actualDmg = hp1 - hp2;
if(actualDmg > 0) {
result.add(new EventBuilder(EventType.TakenDamageEvent)
.withTargetEntity(data.originEntity)
.withTargetField(data.originField)
.withAmount(dmg)
.withAmount(actualDmg)
.buildEntityEvent());
}
result.add(new EventBuilder(EventType.TakenDamageEvent)
.withTargetEntity(data.targetEntity)
.withTargetEntity(targetEntity.id)
.withTargetField(data.targetField)
.withAmount(origin.meleeDamage * 2)
.buildEntityEvent());
result.addAll(checkDeath(state, targetEntity, origin.meleeDamage * 2));
}
case TimeStone -> {
Character origin = (Character)state.entities.findEntity(data.originEntity);
@ -491,9 +560,11 @@ public class GameLogic {
}
}
case SoulStone -> {
Character target = (Character)state.entities.findEntity(data.targetEntity);
ArrayList<Entity> found = state.entities.findByPosition(data.targetField);
Character target = (Character)found.get(0);
result.add(new EventBuilder(EventType.HealedEvent)
.withTargetEntity(data.targetEntity)
.withTargetEntity(target.id)
.withTargetField(data.targetField)
.withAmount(target.hp.getMax())
.buildEntityEvent());
@ -512,6 +583,49 @@ public class GameLogic {
return result;
}
/**
* Checks for death of a Character or Rock and returns the resulting {@link Event Events}.
* @param state The game state to apply to
* @param targetEntity The entity to check
* @param damage The damage taken
* @return The resulting events
*/
private static ArrayList<Event> checkDeath(GameState state, Entity targetEntity, int damage) {
ArrayList<Event> result = new ArrayList<>();
if(targetEntity instanceof Character) {
Character target = (Character)targetEntity;
if(target.hp.getValue() <= damage) {
List<StoneType> stones = Arrays.asList(target.inventory.getStonesAsArray());
Collections.shuffle(stones); // required by documents
ArrayList<IntVector2> used = new ArrayList<>();
for(StoneType stone: stones) {
ArrayList<IntVector2> options = getFreeNeighbour(state, target.getPosition(), used);
IntVector2 picked = options.get(rand.nextInt(options.size()));
used.add(picked);
result.add(new EventBuilder(EventType.SpawnEntityEvent)
.withEntity(new InfinityStone(
new EntityID(EntityType.InfinityStones, stone.getID()),
picked,
stone
))
.buildEntityEvent());
}
}
}else if(targetEntity instanceof Rock) {
Rock target = (Rock)targetEntity;
if(target.getHp() <= damage) {
result.add(new EventBuilder(EventType.DestroyedEntityEvent)
.withTargetField(target.getPosition())
.withTargetEntity(target.id)
.buildEntityEvent());
}
}
return result;
}
/**
* Applies an {@link Event} to a {@link GameState}.
@ -526,7 +640,10 @@ public class GameLogic {
case TakenDamageEvent -> {
EntityEvent data = (EntityEvent)event;
Character target = (Character)state.entities.findEntity(data.targetEntity);
Entity targetEntity = state.entities.findEntity(data.targetEntity);
if(targetEntity instanceof Character) {
Character target = (Character)targetEntity;
target.hp.decreaseValue(data.amount);
EntityType opposing = target.id.type == EntityType.P1 ? EntityType.P2 : EntityType.P1;
@ -540,6 +657,10 @@ public class GameLogic {
NPC thanos = (NPC)state.entities.findEntity(state.activeCharacter);
target.inventory.transfer(thanos.inventory);
}
}else if(targetEntity instanceof Rock) {
Rock target = (Rock)targetEntity;
target.decreaseHp(data.amount);
}
}
case ConsumedAPEvent -> {
EntityEvent data = (EntityEvent)event;

View File

@ -43,6 +43,9 @@ public class EntityDeserializer extends JsonDeserializer<Entity> {
id,
codec.treeToValue(node.get("position"), IntVector2.class),
characterName,
properties.HP,
properties.MP,
properties.AP,
node.get("HP").asInt(),
node.get("MP").asInt(),
node.get("AP").asInt(),

View File

@ -0,0 +1,67 @@
package uulm.teamname.marvelous.gamelibrary;
import net.jqwik.api.*;
import net.jqwik.api.Tuple;
import net.jqwik.api.lifecycle.BeforeProperty;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
class IntVector2Test {
@BeforeEach
@BeforeProperty
void beforeAll() {
}
@Property
@Disabled("This test fails! Therefore,a bug exists here")
void settingLengthToOneResultsInCardinalDirections(
@ForAll int x,
@ForAll int y
) {
var vec = new IntVector2(x, y).setLength(1);
// System.out.printf("Input was (%d, %d). Output is %s\n", x, y, vec);
if (!vec.equals(new IntVector2(0, 0))) {
assertThat(IntVector2.CardinalDirections)
.contains(vec);
}
}
@Property
void settingSmallVectorLengthResultsInCardinalDirections(
@ForAll("SmallIntegers") Integer x,
@ForAll("SmallIntegers") Integer y
) {
var vec = new IntVector2(x, y).setLength(1);
// System.out.printf("Input was (%d, %d). Output is %s\n", x, y, vec);
if (!vec.equals(new IntVector2(0, 0))) {
assertThat(IntVector2.CardinalDirections)
.contains(vec);
}
}
@Test
void setLengthCardinalTest() {
assertThat(new IntVector2(1, 1).setLength(1))
.isEqualTo(new IntVector2(1, 1));
assertThat(new IntVector2(0, 1).setLength(1))
.isEqualTo(new IntVector2(0, 1));
assertThat(new IntVector2(1, -1).setLength(1))
.isEqualTo(new IntVector2(1, -1));
}
@Provide("SmallIntegers")
Arbitrary<Integer> smallIntegers() {
return Arbitraries.integers()
.between(-1000, 1000);
}
}

View File

@ -120,15 +120,27 @@ class MessageValidationUtilityTest {
var characterConfig = new CharacterConfig();
assertThat(ValidationUtility.validate(characterConfig).get())
.isEqualTo("characters must not be empty");
.isIn("characters must not be empty", "characters darf nicht leer sein");
// .isEqualTo("characters must not be empty");
characterConfig.characters = new CharacterProperties[] {racoon, quicksilver, hulk, loki, silversurfer};
assertThat(ValidationUtility.validate(characterConfig).get())
.contains("characters has less than 24 characters",
"characters[4].rangedDamage must be greater than 0",
"characters[4].name must not be empty",
"characters[4].meleeDamage must be greater than 0");
var results = ValidationUtility.validate(characterConfig).get();
assertThat(results.contains("characters has less than 24 characters") ||
results.contains("characters hat weniger als 24 characters"))
.isTrue();
assertThat(results.contains("characters[4].rangedDamage must be greater than 0") ||
results.contains("characters[4].rangedDamage muss größer als 0 sein"))
.isTrue();
assertThat(results.contains("characters[4].name must not be empty") ||
results.contains("characters[4].name darf nicht leer sein"))
.isTrue();
assertThat(results.contains("characters[4].meleeDamage must be greater than 0") ||
results.contains("characters[4].meleeDamage muss größer als 0 sein"))
.isTrue();
}
}