feat(progression): model character points experience and health
This commit is contained in:
1 parent
05f9627cf9
commit
421b302664
4 files changed
+288
No files matched your search
@@ -0,0 +1,85 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Server-independent progression; no disk or Minecraft XP access.
|
||||
* Native adapters must still validate live sessions, reward eligibility and persistence ownership.
|
||||
* This core is not yet registered with crafting, the book or a native session.
|
||||
*/
|
||||
public final class CharacterProgress {
|
||||
public record Snapshot(UUID characterId, Map<Stat, BigInteger> stats, int level,
|
||||
BigInteger experience, BigInteger unspentPoints, BigInteger revision,
|
||||
BigDecimal currentHealth, BigDecimal maximumHealth, String xpDisplay) { }
|
||||
|
||||
private final UUID characterId;
|
||||
private final ProgressionRules rules;
|
||||
private final EnumMap<Stat, BigInteger> stats = new EnumMap<>(Stat.class);
|
||||
private int level = 1;
|
||||
private BigInteger experience = BigInteger.ZERO;
|
||||
private BigInteger points;
|
||||
private BigInteger revision = BigInteger.ZERO;
|
||||
private BigDecimal health = BigDecimal.TEN;
|
||||
|
||||
public CharacterProgress(UUID characterId, ProgressionRules rules) {
|
||||
this.characterId = Objects.requireNonNull(characterId);
|
||||
this.rules = Objects.requireNonNull(rules);
|
||||
this.points = rules.startingPoints();
|
||||
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
|
||||
}
|
||||
|
||||
public synchronized Snapshot snapshot() {
|
||||
return new Snapshot(characterId, Map.copyOf(stats), level, experience, points, revision,
|
||||
health, maximumHealth(), level == rules.maximumLevel() ? "MAX"
|
||||
: experience.remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel());
|
||||
}
|
||||
|
||||
public enum Purchase { APPLIED, WRONG_CHARACTER, STALE_REVISION, NO_POINTS, NOT_ALIVE }
|
||||
|
||||
public synchronized Purchase buyStat(UUID expectedCharacter, BigInteger expectedRevision, Stat stat) {
|
||||
Objects.requireNonNull(stat);
|
||||
if (!characterId.equals(expectedCharacter)) return Purchase.WRONG_CHARACTER;
|
||||
if (!revision.equals(expectedRevision)) return Purchase.STALE_REVISION;
|
||||
if (health.signum() == 0) return Purchase.NOT_ALIVE;
|
||||
if (points.signum() == 0) return Purchase.NO_POINTS;
|
||||
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
|
||||
points = points.subtract(BigInteger.ONE);
|
||||
if (stat == Stat.CON) health = health.add(BigDecimal.ONE);
|
||||
revision = revision.add(BigInteger.ONE);
|
||||
return Purchase.APPLIED;
|
||||
}
|
||||
|
||||
/** Records a health result supplied by a future native session adapter, not a player-facing heal. */
|
||||
public synchronized void recordHealth(BigDecimal remaining) {
|
||||
Objects.requireNonNull(remaining);
|
||||
if (remaining.signum() < 0 || remaining.compareTo(maximumHealth()) > 0) {
|
||||
throw new IllegalArgumentException("Health must be inside the character's pool");
|
||||
}
|
||||
health = remaining;
|
||||
revision = revision.add(BigInteger.ONE);
|
||||
}
|
||||
|
||||
public synchronized int awardExperience(BigInteger reward) {
|
||||
Objects.requireNonNull(reward);
|
||||
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
|
||||
BigInteger cap = rules.xpPerLevel().multiply(BigInteger.valueOf(rules.maximumLevel() - 1L));
|
||||
BigInteger nextExperience = experience.add(reward).min(cap);
|
||||
if (experience.equals(nextExperience)) return 0;
|
||||
int nextLevel = nextExperience.divide(rules.xpPerLevel()).intValueExact() + 1;
|
||||
int gained = nextLevel - level;
|
||||
points = points.add(BigInteger.valueOf(gained));
|
||||
level = nextLevel;
|
||||
experience = nextExperience;
|
||||
revision = revision.add(BigInteger.ONE);
|
||||
return gained;
|
||||
}
|
||||
|
||||
private BigDecimal maximumHealth() {
|
||||
return new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Objects;
|
||||
|
||||
public record ProgressionRules(BigInteger startingPoints, BigInteger xpPerLevel, int maximumLevel) {
|
||||
public ProgressionRules {
|
||||
Objects.requireNonNull(startingPoints);
|
||||
Objects.requireNonNull(xpPerLevel);
|
||||
if (startingPoints.signum() < 0 || xpPerLevel.signum() <= 0 || maximumLevel < 1) {
|
||||
throw new IllegalArgumentException("Invalid progression rules");
|
||||
}
|
||||
}
|
||||
|
||||
public static ProgressionRules defaults() {
|
||||
return new ProgressionRules(BigInteger.valueOf(40), BigInteger.valueOf(100), 30);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
public enum Stat {
|
||||
STR, DEX, CON, INT, WIS, CHR
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.Collections;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CharacterProgressTest {
|
||||
private static BigInteger integer(long value) { return BigInteger.valueOf(value); }
|
||||
|
||||
private static void buy(CharacterProgress character, Stat stat) {
|
||||
var before = character.snapshot();
|
||||
assertEquals(CharacterProgress.Purchase.APPLIED,
|
||||
character.buyStat(before.characterId(), before.revision(), stat));
|
||||
}
|
||||
|
||||
@Test
|
||||
void conAddsItsGainToBothPoolsPreservingTheFiveOfSevenToSevenOfNineHeartExample() {
|
||||
var character = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults());
|
||||
for (int i = 0; i < 4; i++) buy(character, Stat.CON);
|
||||
character.recordHealth(BigDecimal.TEN); // five of seven hearts, in health points
|
||||
for (int i = 0; i < 4; i++) buy(character, Stat.CON);
|
||||
var after = character.snapshot();
|
||||
assertEquals(new BigDecimal("14"), after.currentHealth()); // seven hearts
|
||||
assertEquals(new BigDecimal("18"), after.maximumHealth()); // nine hearts
|
||||
assertEquals(integer(8), after.stats().get(Stat.CON));
|
||||
assertEquals(integer(32), after.unspentPoints());
|
||||
assertEquals(new BigDecimal("4"), after.maximumHealth().subtract(after.currentHealth()));
|
||||
buy(character, Stat.STR);
|
||||
assertEquals(after.currentHealth(), character.snapshot().currentHealth());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongCharacterRepeatedPurchaseInsufficientPointsAndDeadHealthWithoutMutation() {
|
||||
var character = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults());
|
||||
var before = character.snapshot();
|
||||
assertEquals(CharacterProgress.Purchase.WRONG_CHARACTER,
|
||||
character.buyStat(UUID.randomUUID(), before.revision(), Stat.CON));
|
||||
assertEquals(before, character.snapshot());
|
||||
buy(character, Stat.CON);
|
||||
var after = character.snapshot();
|
||||
assertEquals(CharacterProgress.Purchase.STALE_REVISION,
|
||||
character.buyStat(before.characterId(), before.revision(), Stat.CON));
|
||||
assertEquals(after, character.snapshot());
|
||||
character.recordHealth(BigDecimal.ZERO);
|
||||
var defeated = character.snapshot();
|
||||
assertEquals(CharacterProgress.Purchase.NOT_ALIVE,
|
||||
character.buyStat(defeated.characterId(), defeated.revision(), Stat.CON));
|
||||
assertEquals(defeated, character.snapshot());
|
||||
var empty = new CharacterProgress(UUID.randomUUID(), new ProgressionRules(integer(0), integer(100), 30));
|
||||
var emptyBefore = empty.snapshot();
|
||||
assertEquals(CharacterProgress.Purchase.NO_POINTS,
|
||||
empty.buyStat(emptyBefore.characterId(), emptyBefore.revision(), Stat.CON));
|
||||
assertEquals(emptyBefore, empty.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void experienceHandlesThresholdsMultipleLevelsAndTheCapWithoutHealingOrRepeatedPoints() {
|
||||
var character = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults());
|
||||
character.recordHealth(new BigDecimal("5"));
|
||||
assertEquals(0, character.awardExperience(integer(99)));
|
||||
assertEquals("99/100", character.snapshot().xpDisplay());
|
||||
assertEquals(3, character.awardExperience(integer(201)));
|
||||
assertEquals(4, character.snapshot().level());
|
||||
assertEquals(integer(43), character.snapshot().unspentPoints());
|
||||
assertEquals(new BigDecimal("5"), character.snapshot().currentHealth());
|
||||
assertEquals(26, character.awardExperience(BigInteger.TEN.pow(100)));
|
||||
var capped = character.snapshot();
|
||||
assertEquals(30, capped.level());
|
||||
assertEquals(integer(2900), capped.experience());
|
||||
assertEquals(integer(69), capped.unspentPoints());
|
||||
assertEquals("MAX", capped.xpDisplay());
|
||||
assertEquals(0, character.awardExperience(integer(100)));
|
||||
assertEquals(capped, character.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void configurableOneLevelMaximumStartsAtMaxAndCannotAccumulateXp() {
|
||||
var character = new CharacterProgress(UUID.randomUUID(), new ProgressionRules(integer(0), integer(7), 1));
|
||||
assertEquals("MAX", character.snapshot().xpDisplay());
|
||||
assertEquals(0, character.awardExperience(integer(7)));
|
||||
assertEquals(BigInteger.ZERO, character.snapshot().experience());
|
||||
assertEquals(BigInteger.ZERO, character.snapshot().unspentPoints());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidRulesRewardsAndHealthWithoutPartialMutation() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ProgressionRules(integer(-1), integer(100), 30));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ProgressionRules(integer(40), integer(0), 30));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ProgressionRules(integer(40), integer(-1), 30));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ProgressionRules(integer(40), integer(100), 0));
|
||||
var character = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults());
|
||||
var before = character.snapshot();
|
||||
assertThrows(IllegalArgumentException.class, () -> character.awardExperience(integer(-1)));
|
||||
assertEquals(before, character.snapshot());
|
||||
for (String invalid : new String[] {"-0.5", "10.1", "1E100"}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> character.recordHealth(new BigDecimal(invalid)));
|
||||
assertEquals(before, character.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsUncappedStatsLargePointBudgetsAndFractionalHealthDeficits() {
|
||||
BigInteger budget = BigInteger.TEN.pow(100);
|
||||
var character = new CharacterProgress(UUID.randomUUID(), new ProgressionRules(budget, integer(100), 30));
|
||||
for (int i = 0; i < 40; i++) buy(character, Stat.STR);
|
||||
assertEquals(integer(40), character.snapshot().stats().get(Stat.STR));
|
||||
assertEquals(budget.subtract(integer(40)), character.snapshot().unspentPoints());
|
||||
character.recordHealth(new BigDecimal("6.25"));
|
||||
buy(character, Stat.CON);
|
||||
assertEquals(new BigDecimal("7.25"), character.snapshot().currentHealth());
|
||||
assertEquals(new BigDecimal("11"), character.snapshot().maximumHealth());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentRequestsForTheSameRevisionSpendAndHealOnlyOnce() throws Exception {
|
||||
var character = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults());
|
||||
var before = character.snapshot();
|
||||
Callable<CharacterProgress.Purchase> action =
|
||||
() -> character.buyStat(before.characterId(), before.revision(), Stat.CON);
|
||||
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
var results = pool.invokeAll(Collections.nCopies(8, action));
|
||||
int applied = 0;
|
||||
for (var result : results) {
|
||||
var outcome = result.get();
|
||||
if (outcome == CharacterProgress.Purchase.APPLIED) applied++;
|
||||
else assertEquals(CharacterProgress.Purchase.STALE_REVISION, outcome);
|
||||
}
|
||||
assertEquals(1, applied);
|
||||
}
|
||||
assertEquals(integer(39), character.snapshot().unspentPoints());
|
||||
assertEquals(new BigDecimal("11"), character.snapshot().currentHealth());
|
||||
assertEquals(new BigDecimal("11"), character.snapshot().maximumHealth());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesCustomExperienceThresholdsAndDoesNotMutateOnZeroRewards() {
|
||||
var character = new CharacterProgress(UUID.randomUUID(), new ProgressionRules(integer(2), integer(7), 3));
|
||||
var before = character.snapshot();
|
||||
assertEquals(0, character.awardExperience(BigInteger.ZERO));
|
||||
assertEquals(before, character.snapshot());
|
||||
assertEquals(1, character.awardExperience(integer(8)));
|
||||
assertEquals("1/7", character.snapshot().xpDisplay());
|
||||
assertEquals(1, character.awardExperience(integer(100)));
|
||||
assertEquals(integer(14), character.snapshot().experience());
|
||||
assertEquals(integer(4), character.snapshot().unspentPoints());
|
||||
assertEquals("MAX", character.snapshot().xpDisplay());
|
||||
}
|
||||
|
||||
@Test
|
||||
void initializesIndependentCharactersWithZeroStatsFullHealthAndConfiguredPoints() {
|
||||
UUID id = UUID.randomUUID();
|
||||
var defaults = new CharacterProgress(id, ProgressionRules.defaults()).snapshot();
|
||||
assertNotNull(defaults, "A created character must have initial progression");
|
||||
assertEquals(id, defaults.characterId());
|
||||
assertEquals(6, defaults.stats().size());
|
||||
for (Stat stat : Stat.values()) assertEquals(BigInteger.ZERO, defaults.stats().get(stat));
|
||||
assertEquals(1, defaults.level());
|
||||
assertEquals(BigInteger.ZERO, defaults.experience());
|
||||
assertEquals(integer(40), defaults.unspentPoints());
|
||||
assertEquals(BigDecimal.TEN, defaults.currentHealth());
|
||||
assertEquals(BigDecimal.TEN, defaults.maximumHealth());
|
||||
assertEquals("0/100", defaults.xpDisplay());
|
||||
var customized = new CharacterProgress(UUID.randomUUID(),
|
||||
new ProgressionRules(integer(7), integer(100), 30)).snapshot();
|
||||
assertEquals(integer(7), customized.unspentPoints());
|
||||
assertNotEquals(defaults.characterId(), customized.characterId());
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> defaults.stats().put(Stat.STR, integer(99)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user