feat(progression): model character points experience and health

This commit is contained in:
dmg committed 2026-09-16 22:17:27 -04:00
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)));
}
}