Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 9cc6ea9a02 Fix duplicate heroes appearing in games
HeroGenerator.getHeroExcluding() now skips heroes whose nameTextId
is already used in the game. PerformUnaffiliatedHeroesAction passes
existing hero nameTextIds to avoid selecting the same pregenerated
hero multiple times.

Cost is O(k) where k = duplicates to skip (usually 0), not O(n)
where n = total pregenerated heroes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 23:33:27 -08:00
2 changed files with 33 additions and 8 deletions
@@ -297,10 +297,14 @@ case class PerformUnaffiliatedHeroesAction(
gs: GameState,
fr: FunctionalRandom
): RandomState[Vector[(ProvinceId, HeroC, NameInfo)]] = {
// Collect nameTextIds of all existing heroes to avoid duplicates
val existingNameTextIds = gs.heroes.values.map(_.nameTextId).toSet
def go(
toCheck: Iterable[ProvinceId],
nextHid: HeroId,
currentGenerator: HeroGenerator,
usedNameTextIds: Set[String],
acc: RandomState[Vector[(ProvinceId, HeroC, NameInfo)]]
): RandomState[Vector[(ProvinceId, HeroC, NameInfo)]] =
acc.continue {
@@ -310,12 +314,13 @@ case class PerformUnaffiliatedHeroesAction(
fr.nextDouble match {
case RandomState(d, nextFr) =>
if d >= NewHeroChance.doubleValue then
go(toCheck.drop(1), nextHid, currentGenerator, RandomState(innerAcc, nextFr))
go(toCheck.drop(1), nextHid, currentGenerator, usedNameTextIds, RandomState(innerAcc, nextFr))
else
currentGenerator
.getHero(
.getHeroExcluding(
random = nextFr,
hid = nextHid
hid = nextHid,
usedNameTextIds = usedNameTextIds
)
.continue {
case ((HeroGenerationResponse(hero, nameInfo), newGenerator), fr2) =>
@@ -323,6 +328,7 @@ case class PerformUnaffiliatedHeroesAction(
toCheck.drop(1),
nextHid + 1,
newGenerator,
usedNameTextIds + hero.nameTextId,
RandomState(
innerAcc :+ (toCheck.head, hero.copy(id = nextHid), nameInfo),
fr2
@@ -336,6 +342,7 @@ case class PerformUnaffiliatedHeroesAction(
toCheck = gs.provinces.keys,
nextHid = gs.heroes.values.map(_.id).max + 1,
currentGenerator = heroGenerator,
usedNameTextIds = existingNameTextIds,
acc = RandomState(Vector(), fr)
)
}
@@ -44,19 +44,37 @@ class HeroGenerator private (
final def getHero(
random: FunctionalRandom,
hid: HeroId
): RandomState[(HeroGenerationResponse, HeroGenerator)] =
getHeroExcluding(random, hid, Set.empty)
/**
* Get a hero, skipping any whose nameTextId is in the usedNameTextIds set. This prevents duplicate heroes when the
* generator state isn't properly preserved between rounds.
*
* Cost: O(k) where k is the number of duplicates to skip (usually 0).
*/
final def getHeroExcluding(
random: FunctionalRandom,
hid: HeroId,
usedNameTextIds: Set[String]
): RandomState[(HeroGenerationResponse, HeroGenerator)] = {
if remainingPregeneratedHeroes.isEmpty then
// Find the first hero not already used
val (skipped, remaining) = remainingPregeneratedHeroes.span(h => usedNameTextIds.contains(h.hero.nameTextId))
if remaining.isEmpty then
throw new EagleInternalException(
s"Pregenerated heroes exhausted, no hero with id $hid"
s"No pregenerated heroes available (skipped ${skipped.size} duplicates, no hero with id $hid)"
)
val nextHero = remainingPregeneratedHeroes.head
.copy(hero = remainingPregeneratedHeroes.head.hero.copy(id = hid))
val nextHero = remaining.head
.copy(hero = remaining.head.hero.copy(id = hid))
internalRequire(
nextHero.hero.backstoryVersions.nonEmpty,
s"Pregenerated hero ${nextHero.name} has no backstory text"
)
val response = HeroGenerationResponse(hero = nextHero.hero, nameInfo = NoNameRequest)
val newGenerator = new HeroGenerator(remainingPregeneratedHeroes.tail)
// Remove the used hero and any skipped ones from the pool
val newGenerator = new HeroGenerator(remaining.tail)
RandomState((response, newGenerator), random)
}
}