mirror of
https://github.com/rr-/pq-cli.git
synced 2026-11-12 22:40:36 -07:00
Upstream calls CompleteAct() when it dequeues the plot task, so the act number, the plot bar and the act rewards change as "Loading Act N" begins. pqcli did all that when the task ended. The random sequence is the same either way, so the parity traces are unchanged; only the 1-2 seconds of the loading task differ.
1042 lines
31 KiB
Python
1042 lines
31 KiB
Python
import datetime
|
|
import functools
|
|
import itertools
|
|
import logging
|
|
import math
|
|
from collections import defaultdict
|
|
from collections.abc import Callable, Iterator, Sequence
|
|
from dataclasses import dataclass
|
|
from typing import Any, cast
|
|
|
|
from pqcli import random
|
|
from pqcli.config import (
|
|
PRIME_STATS,
|
|
Class,
|
|
EquipmentPreset,
|
|
EquipmentType,
|
|
Modifier,
|
|
Monster,
|
|
Race,
|
|
StatType,
|
|
)
|
|
from pqcli.game_data import TARGET_PLACEHOLDER, GameData
|
|
from pqcli.lingo import (
|
|
act_name,
|
|
big,
|
|
definite,
|
|
generate_name,
|
|
indefinite,
|
|
plural,
|
|
sick,
|
|
special,
|
|
young,
|
|
)
|
|
from pqcli.random import Alea, RngState
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_SIGNALS: dict[tuple["SignalMixin", str], list[Callable[..., Any]]] = (
|
|
defaultdict(list)
|
|
)
|
|
|
|
|
|
class SignalMixin:
|
|
def emit(self, signal_name: str, *user_data: Any) -> None:
|
|
for callback in _SIGNALS[self, signal_name]:
|
|
callback(*user_data)
|
|
|
|
def connect(self, signal_name: str, callback: Callable[..., Any]) -> None:
|
|
_SIGNALS[self, signal_name].append(callback)
|
|
|
|
def disconnect(
|
|
self, signal_name: str, callback: Callable[..., Any]
|
|
) -> None:
|
|
try:
|
|
idx = _SIGNALS[self, signal_name].index(callback)
|
|
except ValueError:
|
|
return
|
|
del _SIGNALS[self, signal_name][idx]
|
|
|
|
|
|
def level_up_time(level: int) -> int:
|
|
# seconds: 20 minutes for level 1, exponential increase after that.
|
|
# floor(x + 0.5) mirrors JS Math.round, unlike Python's round().
|
|
return math.floor((20 + 1.15**level) * 60 + 0.5)
|
|
|
|
|
|
class Bar(SignalMixin):
|
|
signals = ["change"]
|
|
|
|
def __init__(self, max_: int, position: float = 0.0) -> None:
|
|
self._position = position
|
|
self._max = max_
|
|
self.emit("change")
|
|
|
|
def reset(self, new_max: int, position: float = 0.0) -> None:
|
|
if not math.isclose(new_max, self._max) or not math.isclose(
|
|
position, self._position
|
|
):
|
|
self._max = new_max
|
|
self._position = position
|
|
self.emit("change")
|
|
|
|
def increment(self, inc: float) -> None:
|
|
self.reposition(self._position + inc)
|
|
|
|
@property
|
|
def max_(self) -> int:
|
|
return self._max
|
|
|
|
@property
|
|
def position(self) -> float:
|
|
return self._position
|
|
|
|
@property
|
|
def done(self) -> bool:
|
|
return self._position >= self.max_
|
|
|
|
def reposition(self, new_pos: float) -> None:
|
|
old_pos = self._position
|
|
new_pos = float(min(new_pos, self._max))
|
|
self._position = new_pos
|
|
if not math.isclose(new_pos, old_pos):
|
|
self.emit("change")
|
|
|
|
|
|
class Stats(SignalMixin):
|
|
signals = ["change"]
|
|
|
|
def __init__(self, values: dict[StatType, int]) -> None:
|
|
self._values = values
|
|
|
|
def __iter__(self) -> Iterator[tuple[StatType, int]]:
|
|
return iter(self._values.items())
|
|
|
|
def __getitem__(self, stat: StatType) -> int:
|
|
return self._values[stat]
|
|
|
|
@property
|
|
def best(self) -> StatType:
|
|
return max(self, key=lambda kv: kv[1])[0]
|
|
|
|
@property
|
|
def best_prime(self) -> StatType:
|
|
return max(
|
|
(kv for kv in self if kv[0] in PRIME_STATS), key=lambda kv: kv[1]
|
|
)[0]
|
|
|
|
def increment(self, stat: StatType, qty: int = 1) -> None:
|
|
self._values[stat] += qty
|
|
logger.info("Increased %s to %d", stat.value, self[stat])
|
|
self.emit("change")
|
|
|
|
|
|
class QuestBook(SignalMixin):
|
|
signals = ["start_act", "start_quest"]
|
|
|
|
def __init__(self) -> None:
|
|
self._quests: list[str] = []
|
|
self._act = 0
|
|
self.plot_bar = Bar(max_=1)
|
|
self.quest_bar = Bar(max_=1)
|
|
self.monster: Monster | None = None
|
|
|
|
@property
|
|
def act(self) -> int:
|
|
return self._act
|
|
|
|
@property
|
|
def quests(self) -> list[str]:
|
|
return self._quests
|
|
|
|
@property
|
|
def current_quest(self) -> str | None:
|
|
if self._quests:
|
|
return self._quests[-1]
|
|
return None
|
|
|
|
def increment_act(self) -> None:
|
|
self._act += 1
|
|
self.emit("start_act", self._act)
|
|
|
|
def add_quest(self, name: str) -> None:
|
|
logger.info("Commencing quest: %s", name)
|
|
self._quests = self._quests[-99:]
|
|
self._quests.append(name)
|
|
|
|
|
|
@dataclass
|
|
class InventoryItem:
|
|
name: str
|
|
quantity: int
|
|
|
|
|
|
class Inventory(SignalMixin):
|
|
signals = ["gold_change", "item_add", "item_change", "item_del"]
|
|
|
|
def __init__(self, capacity: int = 0) -> None:
|
|
self._gold = 0
|
|
self._items: list[InventoryItem] = []
|
|
self.encum_bar = Bar(max_=capacity)
|
|
|
|
@property
|
|
def gold(self) -> int:
|
|
return self._gold
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._items)
|
|
|
|
def __iter__(self) -> Iterator[InventoryItem]:
|
|
return iter(self._items)
|
|
|
|
def __getitem__(self, idx: int) -> InventoryItem:
|
|
return self._items[idx]
|
|
|
|
def add_gold(self, quantity: int) -> None:
|
|
logger.info(
|
|
"%s %s",
|
|
"Spent" if quantity < 0 else "Got paid",
|
|
indefinite("gold piece", abs(quantity)),
|
|
)
|
|
self._gold += quantity
|
|
self.emit("gold_change")
|
|
|
|
def pop(self, index: int) -> None:
|
|
item = self._items[index]
|
|
logger.info("Lost %s", indefinite(item.name, item.quantity))
|
|
self._items.pop(index)
|
|
self.sync_encumbrance()
|
|
self.emit("item_del", item)
|
|
|
|
def add(self, item_name: str, quantity: int) -> None:
|
|
logger.info("Gained %s", indefinite(item_name, quantity))
|
|
for item in self._items:
|
|
if item.name == item_name:
|
|
item.quantity += quantity
|
|
self.emit("item_change", item)
|
|
break
|
|
else:
|
|
item = InventoryItem(name=item_name, quantity=quantity)
|
|
self._items.append(item)
|
|
self.emit("item_add", item)
|
|
self.sync_encumbrance()
|
|
|
|
def sync_encumbrance(self) -> None:
|
|
self.encum_bar.reposition(sum(item.quantity for item in self._items))
|
|
|
|
def set_capacity(self, capacity: int) -> None:
|
|
self.encum_bar.reset(capacity, self.encum_bar.position)
|
|
|
|
|
|
class Equipment(SignalMixin):
|
|
signals = ["change"]
|
|
|
|
def __init__(self) -> None:
|
|
self._items: dict[EquipmentType, str] = {
|
|
EquipmentType.weapon: "Sharp Rock",
|
|
EquipmentType.hauberk: "-3 Burlap",
|
|
}
|
|
self.best = self[EquipmentType.weapon]
|
|
|
|
def __iter__(self) -> Iterator[tuple[EquipmentType, str]]:
|
|
return iter(self._items.items())
|
|
|
|
def __getitem__(self, equipment_type: EquipmentType) -> str | None:
|
|
return self._items.get(equipment_type, None)
|
|
|
|
def put(self, equipment_type: EquipmentType, item_name: str) -> None:
|
|
self._items[equipment_type] = item_name
|
|
self.emit("change", equipment_type, item_name)
|
|
self.best = item_name + (
|
|
" " + equipment_type.value
|
|
if equipment_type
|
|
not in {EquipmentType.weapon, EquipmentType.shield}
|
|
else ""
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Spell:
|
|
name: str
|
|
level: int
|
|
|
|
|
|
class SpellBook(SignalMixin):
|
|
signals = ["add", "change"]
|
|
|
|
def __init__(self) -> None:
|
|
self._spells: list[Spell] = []
|
|
|
|
def __iter__(self) -> Iterator[Spell]:
|
|
return iter(self._spells)
|
|
|
|
def __bool__(self) -> bool:
|
|
return bool(self._spells)
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._spells)
|
|
|
|
def add(self, spell_name: str, level: int) -> None:
|
|
for spell in self._spells:
|
|
if spell.name == spell_name:
|
|
spell.level += level
|
|
logger.info("Learned %s at level %d", spell_name, spell.level)
|
|
self.emit("change", spell)
|
|
break
|
|
else:
|
|
spell = Spell(name=spell_name, level=level)
|
|
self._spells.append(spell)
|
|
logger.info("Learned %s at level %d", spell.name, spell.level)
|
|
self.emit("add", spell)
|
|
|
|
@property
|
|
def best(self) -> Spell | None:
|
|
if not self._spells:
|
|
return None
|
|
return max(self._spells, key=lambda spell: spell.level)
|
|
|
|
|
|
@dataclass
|
|
class BaseTask:
|
|
description: str
|
|
duration: int
|
|
|
|
|
|
@dataclass
|
|
class KillTask(BaseTask):
|
|
monster: Monster | None = None
|
|
|
|
|
|
class BuyTask(BaseTask):
|
|
pass
|
|
|
|
|
|
class HeadingToKillingFieldsTask(BaseTask):
|
|
pass
|
|
|
|
|
|
class HeadingToMarketTask(BaseTask):
|
|
pass
|
|
|
|
|
|
class SellTask(BaseTask):
|
|
pass
|
|
|
|
|
|
class RegularTask(BaseTask):
|
|
pass
|
|
|
|
|
|
class PlotTask(RegularTask):
|
|
pass
|
|
|
|
|
|
class LoadingTask(RegularTask):
|
|
pass
|
|
|
|
|
|
class Player(SignalMixin):
|
|
signals = ["new_task", "level_up"]
|
|
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
birthday: datetime.datetime,
|
|
race: Race,
|
|
class_: Class,
|
|
stats: Stats,
|
|
rng: Alea | None = None,
|
|
) -> None:
|
|
self.name: str = name
|
|
self.birthday: datetime.datetime = birthday
|
|
self.race: Race = race
|
|
self.class_: Class = class_
|
|
self.stats: Stats = stats
|
|
self.rng: Alea = rng or Alea()
|
|
self.elapsed = 0.0
|
|
|
|
self.exp_bar = Bar(max_=level_up_time(1))
|
|
self.level = 1
|
|
|
|
self.quest_book = QuestBook()
|
|
self.inventory = Inventory(capacity=10 + self.stats[StatType.strength])
|
|
self.equipment = Equipment()
|
|
self.spell_book = SpellBook()
|
|
|
|
self.task_bar = Bar(max_=1)
|
|
self.task: BaseTask | None = None
|
|
self.queue: list[BaseTask] = []
|
|
|
|
def __setstate__(self, obj: Any) -> None:
|
|
self.__dict__.update(obj)
|
|
self.elapsed = obj.get("elapsed", 0.0)
|
|
if "rng" not in obj:
|
|
self.rng = Alea()
|
|
|
|
def set_task(self, task: BaseTask) -> None:
|
|
self.task = task
|
|
self.task_bar.reset(task.duration)
|
|
logger.info("%s...", task.description)
|
|
self.emit("new_task")
|
|
|
|
def equip_price(self) -> int:
|
|
return 5 * (self.level**2) + 10 * self.level + 20
|
|
|
|
def level_up(self, data: GameData) -> None:
|
|
self.level += 1
|
|
logger.info("Leveled up to level %d!", self.level)
|
|
self.stats.increment(
|
|
StatType.hp_max,
|
|
self.stats[StatType.condition] // 3 + 1 + random.below(4),
|
|
)
|
|
self.stats.increment(
|
|
StatType.mp_max,
|
|
self.stats[StatType.intelligence] // 3 + 1 + random.below(4),
|
|
)
|
|
self.win_stat()
|
|
self.win_stat()
|
|
self.win_spell(data)
|
|
self.exp_bar.reset(level_up_time(self.level))
|
|
self.emit("level_up")
|
|
|
|
def win_stat(self) -> bool:
|
|
chosen_stat: StatType | None = None
|
|
|
|
if random.odds(1, 2):
|
|
chosen_stat = random.choice(list(StatType))
|
|
else:
|
|
# favor the best stat so it will tend to clump
|
|
t = sum(self.stats[stat] ** 2 for stat in PRIME_STATS)
|
|
t = random.below(t)
|
|
chosen_stat = None
|
|
for stat in PRIME_STATS:
|
|
chosen_stat = stat
|
|
t -= self.stats[stat] ** 2
|
|
if t < 0:
|
|
break
|
|
|
|
assert chosen_stat is not None
|
|
self.stats.increment(chosen_stat)
|
|
if chosen_stat == StatType.strength:
|
|
self.inventory.set_capacity(10 + self.stats[StatType.strength])
|
|
return True
|
|
|
|
def win_spell(self, data: GameData) -> None:
|
|
spells = data.spells
|
|
self.spell_book.add(
|
|
spells[
|
|
random.below_low(
|
|
min(self.stats[StatType.wisdom] + self.level, len(spells))
|
|
)
|
|
],
|
|
1,
|
|
)
|
|
|
|
def win_equipment(self, data: GameData) -> None:
|
|
choice = random.choice(list(EquipmentType))
|
|
|
|
stuff: Sequence[EquipmentPreset]
|
|
better: Sequence[Modifier]
|
|
worse: Sequence[Modifier]
|
|
|
|
if choice == EquipmentType.weapon:
|
|
stuff = data.weapons
|
|
better = data.offense_attrib
|
|
worse = data.offense_bad
|
|
else:
|
|
stuff = (
|
|
data.shields if choice == EquipmentType.shield else data.armors
|
|
)
|
|
better = data.defense_attrib
|
|
worse = data.defense_bad
|
|
|
|
equipment = pick_equipment(stuff, self.level)
|
|
name = equipment.name
|
|
plus = self.level - equipment.quality
|
|
if plus < 0:
|
|
modifier_pool = worse
|
|
else:
|
|
modifier_pool = better
|
|
count = 0
|
|
while count < 2 and plus:
|
|
modifier = random.choice(modifier_pool)
|
|
if modifier.name in name:
|
|
break # no repeats
|
|
if abs(plus) < abs(modifier.quality):
|
|
break # too much
|
|
name = modifier.name + " " + name
|
|
plus -= modifier.quality
|
|
count += 1
|
|
|
|
if plus < 0:
|
|
name = f"{plus} {name}"
|
|
if plus > 0:
|
|
name = f"+{plus} {name}"
|
|
|
|
logger.info("Gained %s %s", name, choice.value)
|
|
self.equipment.put(choice, name)
|
|
|
|
def win_item(self, data: GameData) -> None:
|
|
# Past 250 rows, sometimes add one more of something already carried.
|
|
# Upstream counts the Gold row too, and picking it yields 1 gold.
|
|
rows = len(self.inventory) + 1
|
|
if max(250, random.below(999)) < rows:
|
|
index = random.below(rows)
|
|
if index == 0:
|
|
self.inventory.add_gold(1)
|
|
else:
|
|
self.inventory.add(self.inventory[index - 1].name, 1)
|
|
else:
|
|
self.inventory.add(special_item(data), 1)
|
|
|
|
|
|
class Simulation:
|
|
def __init__(self, player: Player, data: GameData) -> None:
|
|
self.player = player
|
|
self.data = data
|
|
self.last_tick = datetime.datetime.now()
|
|
|
|
def tick(self, elapsed: float = 100.0) -> None:
|
|
# each character plays on its own generator, saved along with it
|
|
with random.using(self.player.rng):
|
|
self._tick(elapsed)
|
|
|
|
def _tick(self, elapsed: float) -> None:
|
|
self.player.elapsed += elapsed
|
|
|
|
if self.player.task is None:
|
|
self.player.set_task(LoadingTask("Loading", 2000))
|
|
self.player.queue += [
|
|
RegularTask(
|
|
"Experiencing an enigmatic and foreboding night vision",
|
|
10000,
|
|
),
|
|
RegularTask(
|
|
"Much is revealed about that wise old bastard "
|
|
"you'd underestimated",
|
|
6000,
|
|
),
|
|
RegularTask(
|
|
"A shocking series of events leaves you "
|
|
"alone and bewildered, but resolute",
|
|
6000,
|
|
),
|
|
RegularTask(
|
|
"Drawing upon an unrealized reserve of determination, "
|
|
"you set out on a long and dangerous journey",
|
|
4000,
|
|
),
|
|
PlotTask(f"Loading {act_name(1)}", 2000),
|
|
]
|
|
self.player.quest_book.plot_bar.reset(26)
|
|
return
|
|
|
|
if not self.player.task_bar.done:
|
|
self.player.task_bar.increment(elapsed)
|
|
return
|
|
|
|
# gain XP / level up
|
|
gain = self.player.task and isinstance(self.player.task, KillTask)
|
|
if gain:
|
|
if self.player.exp_bar.done:
|
|
self.player.level_up(self.data)
|
|
else:
|
|
self.player.exp_bar.increment(self.player.task_bar.max_ / 1000)
|
|
|
|
# advance quest
|
|
if gain and self.player.quest_book.act >= 1:
|
|
if (
|
|
self.player.quest_book.quest_bar.done
|
|
or self.player.quest_book.current_quest is None
|
|
):
|
|
self.complete_quest()
|
|
else:
|
|
self.player.quest_book.quest_bar.increment(
|
|
self.player.task_bar.max_ / 1000
|
|
)
|
|
|
|
# advance plot
|
|
if gain:
|
|
if self.player.quest_book.plot_bar.done:
|
|
self.interplot_cinematic()
|
|
else:
|
|
self.player.quest_book.plot_bar.increment(
|
|
self.player.task_bar.max_ / 1000
|
|
)
|
|
elif self.player.quest_book.act == 0 and not isinstance(
|
|
self.player.task, LoadingTask | PlotTask
|
|
):
|
|
# the Prologue story tasks fill the plot bar
|
|
self.player.quest_book.plot_bar.increment(
|
|
self.player.task_bar.max_ / 1000
|
|
)
|
|
|
|
self.dequeue()
|
|
|
|
def dequeue(self) -> None:
|
|
while self.player.task_bar.done:
|
|
if isinstance(self.player.task, KillTask):
|
|
if (
|
|
self.player.task.monster is None
|
|
or self.player.task.monster.item is None
|
|
):
|
|
# npc
|
|
self.player.win_item(self.data)
|
|
elif self.player.task.monster.item:
|
|
self.player.inventory.add(
|
|
(
|
|
self.player.task.monster.name
|
|
+ " "
|
|
+ self.player.task.monster.item
|
|
).lower(),
|
|
1,
|
|
)
|
|
|
|
elif isinstance(self.player.task, BuyTask):
|
|
# buy some equipment
|
|
self.player.inventory.add_gold(-self.player.equip_price())
|
|
self.player.win_equipment(self.data)
|
|
|
|
elif isinstance(self.player.task, HeadingToMarketTask | SellTask):
|
|
if isinstance(self.player.task, SellTask):
|
|
item = self.player.inventory[0]
|
|
amount = item.quantity * self.player.level
|
|
if " of " in item.name:
|
|
amount *= (1 + random.below_low(10)) * (
|
|
1 + random.below_low(self.player.level)
|
|
)
|
|
self.player.inventory.pop(0)
|
|
self.player.inventory.add_gold(amount)
|
|
if len(self.player.inventory):
|
|
item = self.player.inventory[0]
|
|
self.player.set_task(
|
|
SellTask(
|
|
"Selling " + indefinite(item.name, item.quantity),
|
|
1000,
|
|
)
|
|
)
|
|
break
|
|
|
|
old = self.player.task
|
|
if self.player.queue:
|
|
task = self.player.queue.pop(0)
|
|
if isinstance(task, PlotTask):
|
|
# the act starts as its loading task does, as upstream
|
|
self.complete_act()
|
|
task = PlotTask(
|
|
f"Loading {act_name(self.player.quest_book.act)}",
|
|
task.duration,
|
|
)
|
|
self.player.set_task(task)
|
|
elif self.player.inventory.encum_bar.done:
|
|
self.player.set_task(
|
|
HeadingToMarketTask("Heading to market to sell loot", 4000)
|
|
)
|
|
elif not isinstance(old, KillTask | HeadingToKillingFieldsTask):
|
|
if self.player.inventory.gold > self.player.equip_price():
|
|
self.player.set_task(
|
|
BuyTask(
|
|
"Negotiating purchase of better equipment", 5000
|
|
)
|
|
)
|
|
else:
|
|
self.player.set_task(
|
|
HeadingToKillingFieldsTask(
|
|
"Heading to the killing fields", 4000
|
|
)
|
|
)
|
|
else:
|
|
self.player.set_task(
|
|
monster_task(
|
|
self.data,
|
|
self.player.level,
|
|
self.player.quest_book.monster,
|
|
)
|
|
)
|
|
|
|
def complete_act(self) -> None:
|
|
self.player.quest_book.increment_act()
|
|
self.player.quest_book.plot_bar.reset(
|
|
60 * 60 * (1 + 5 * self.player.quest_book.act)
|
|
)
|
|
if self.player.quest_book.act > 1:
|
|
self.player.win_item(self.data)
|
|
self.player.win_equipment(self.data)
|
|
|
|
def complete_quest(self) -> None:
|
|
self.player.quest_book.quest_bar.reset(50 + random.below(100))
|
|
if self.player.quest_book.current_quest:
|
|
logger.info(
|
|
"Quest completed: %s", self.player.quest_book.current_quest
|
|
)
|
|
random.choice(
|
|
[
|
|
functools.partial(self.player.win_spell, self.data),
|
|
functools.partial(self.player.win_equipment, self.data),
|
|
self.player.win_stat,
|
|
functools.partial(self.player.win_item, self.data),
|
|
]
|
|
)()
|
|
|
|
self.player.quest_book.monster = None
|
|
caption = ""
|
|
choice = random.below(5)
|
|
if choice == 0:
|
|
self.player.quest_book.monster = unnamed_monster(
|
|
self.data, self.player.level, iterations=3
|
|
)
|
|
caption = "Exterminate " + definite(
|
|
self.player.quest_book.monster.name, 2
|
|
)
|
|
elif choice == 1:
|
|
caption = "Seek " + definite(interesting_item(self.data), 1)
|
|
elif choice == 2:
|
|
caption = "Deliver this " + boring_item(self.data)
|
|
elif choice == 3:
|
|
caption = "Fetch me " + indefinite(boring_item(self.data), 1)
|
|
elif choice == 4:
|
|
monster = unnamed_monster(
|
|
self.data, self.player.level, iterations=1
|
|
)
|
|
caption = "Placate " + definite(monster.name, 2)
|
|
else:
|
|
raise AssertionError
|
|
|
|
self.player.quest_book.add_quest(caption)
|
|
self.player.quest_book.emit("start_quest", caption)
|
|
|
|
def interplot_cinematic(self) -> None:
|
|
def enqueue(task: BaseTask) -> None:
|
|
self.player.queue.append(task)
|
|
self.dequeue()
|
|
|
|
choice = random.below(3)
|
|
if choice == 0:
|
|
enqueue(
|
|
RegularTask(
|
|
"Exhausted, you arrive at a friendly oasis "
|
|
"in a hostile land",
|
|
1000,
|
|
)
|
|
)
|
|
enqueue(
|
|
RegularTask("You greet old friends and meet new allies", 2000)
|
|
)
|
|
enqueue(
|
|
RegularTask(
|
|
"You are privy to a council of powerful do-gooders", 2000
|
|
)
|
|
)
|
|
enqueue(
|
|
RegularTask("There is much to be done. You are chosen!", 1000)
|
|
)
|
|
|
|
elif choice == 1:
|
|
enqueue(
|
|
RegularTask(
|
|
"Your quarry is in sight, "
|
|
"but a mighty enemy bars your path!",
|
|
1000,
|
|
)
|
|
)
|
|
|
|
nemesis = named_monster(self.data, self.player.level + 3)
|
|
|
|
enqueue(
|
|
RegularTask(
|
|
f"A desperate struggle commences with {nemesis}", 4000
|
|
)
|
|
)
|
|
|
|
s = random.below(3)
|
|
for i in itertools.count(start=1):
|
|
if i > random.below(1 + self.player.quest_book.act + 1):
|
|
break
|
|
s += 1 + random.below(2)
|
|
if s % 3 == 0:
|
|
enqueue(
|
|
RegularTask(
|
|
f"Locked in grim combat with {nemesis}", 2000
|
|
)
|
|
)
|
|
elif s % 3 == 1:
|
|
enqueue(
|
|
RegularTask(
|
|
f"{nemesis} seems to have the upper hand", 2000
|
|
)
|
|
)
|
|
elif s % 3 == 2:
|
|
enqueue(
|
|
RegularTask(
|
|
f"You seem to gain the advantage over {nemesis}",
|
|
2000,
|
|
)
|
|
)
|
|
else:
|
|
raise AssertionError()
|
|
|
|
enqueue(
|
|
RegularTask(
|
|
f"Victory! {nemesis} is slain! "
|
|
"Exhausted, you lose consciousness",
|
|
3000,
|
|
)
|
|
)
|
|
enqueue(
|
|
RegularTask(
|
|
"You awake in a friendly place, but the road awaits", 2000
|
|
)
|
|
)
|
|
|
|
elif choice == 2:
|
|
nemesis = impressive_guy(self.data)
|
|
enqueue(
|
|
RegularTask(
|
|
"Oh sweet relief! "
|
|
f"You've reached the kind protection of {nemesis}",
|
|
2000,
|
|
)
|
|
)
|
|
enqueue(
|
|
RegularTask(
|
|
"There is rejoicing, "
|
|
f"and an unnerving encounter with {nemesis} in private",
|
|
3000,
|
|
)
|
|
)
|
|
enqueue(
|
|
RegularTask(
|
|
f"You forget your {boring_item(self.data)} "
|
|
"and go back to get it",
|
|
2000,
|
|
)
|
|
)
|
|
enqueue(
|
|
RegularTask(
|
|
"What's this!? You overhear something shocking!", 2000
|
|
)
|
|
)
|
|
enqueue(
|
|
RegularTask(f"Could {nemesis} be a dirty double-dealer?", 2000)
|
|
)
|
|
enqueue(
|
|
RegularTask(
|
|
"Who can possibly be trusted with this news!? -- "
|
|
"Oh yes, of course",
|
|
3000,
|
|
)
|
|
)
|
|
|
|
else:
|
|
raise AssertionError
|
|
|
|
enqueue(
|
|
PlotTask(
|
|
f"Loading {act_name(self.player.quest_book.act + 1)}", 1000
|
|
)
|
|
)
|
|
|
|
|
|
def special_item(data: GameData) -> str:
|
|
return (
|
|
interesting_item(data)
|
|
+ " of "
|
|
+ cast(str, random.choice(data.item_ofs))
|
|
)
|
|
|
|
|
|
def interesting_item(data: GameData) -> str:
|
|
return (
|
|
cast(str, random.choice(data.item_attrib))
|
|
+ " "
|
|
+ cast(str, random.choice(data.specials))
|
|
)
|
|
|
|
|
|
def boring_item(data: GameData) -> str:
|
|
return cast(str, random.choice(data.boring_items))
|
|
|
|
|
|
def impressive_guy(data: GameData) -> str:
|
|
if random.below(2):
|
|
title = cast(str, random.choice(data.impressive_titles))
|
|
race = cast(Race, random.choice(data.races))
|
|
return f"the {title} of the {plural(race.name)}"
|
|
title = cast(str, random.choice(data.impressive_titles))
|
|
return f"{title} {generate_name()} of {generate_name()}"
|
|
|
|
|
|
def unnamed_monster(data: GameData, level: int, iterations: int) -> Monster:
|
|
result = cast(Monster, random.choice(data.monsters))
|
|
for _ in range(iterations):
|
|
alternative = cast(Monster, random.choice(data.monsters))
|
|
if abs(level - alternative.level) < abs(level - result.level):
|
|
result = alternative
|
|
return result
|
|
|
|
|
|
def named_monster(data: GameData, level: int) -> str:
|
|
monster = unnamed_monster(data, level, iterations=4)
|
|
return generate_name() + " the " + monster.name
|
|
|
|
|
|
def pick_equipment(
|
|
source: Sequence[EquipmentPreset], goal: int
|
|
) -> EquipmentPreset:
|
|
result = cast(EquipmentPreset, random.choice(source))
|
|
for _ in range(5):
|
|
alternative = cast(EquipmentPreset, random.choice(source))
|
|
if abs(goal - alternative.quality) < abs(goal - result.quality):
|
|
result = alternative
|
|
return result
|
|
|
|
|
|
def monster_task(
|
|
data: GameData, player_level: int, quest_monster: Monster | None
|
|
) -> KillTask:
|
|
level = player_level
|
|
for _ in range(level):
|
|
if random.odds(2, 5):
|
|
level += random.below(2) * 2 - 1
|
|
if level < 1:
|
|
level = 1
|
|
|
|
is_definite = False
|
|
monster: Monster | None = None
|
|
if random.odds(1, 25):
|
|
# use an NPC every once in a while
|
|
race = random.choice(data.races)
|
|
if random.odds(1, 2):
|
|
result = (
|
|
"passing " + race.name + " " + random.choice(data.classes).name
|
|
)
|
|
else:
|
|
result = (
|
|
random.choice_low(data.titles)
|
|
+ " "
|
|
+ generate_name()
|
|
+ " the "
|
|
+ race.name
|
|
)
|
|
is_definite = True
|
|
lev = level
|
|
elif quest_monster and random.odds(1, 4):
|
|
# use the quest monster
|
|
monster = quest_monster
|
|
result = monster.name
|
|
lev = monster.level
|
|
else:
|
|
# pick the monster out of so many random ones closest to the level
|
|
# we want
|
|
monster = unnamed_monster(data, level, iterations=5)
|
|
result = monster.name
|
|
lev = monster.level
|
|
|
|
qty = 1
|
|
if level - lev > 10:
|
|
# lev is too low. multiply
|
|
qty = (level + random.below(max(lev, 1))) // max(lev, 1)
|
|
if qty < 1:
|
|
qty = 1
|
|
level //= qty
|
|
|
|
if level - lev <= -10:
|
|
result = "imaginary " + result
|
|
elif level - lev < -5:
|
|
i = 10 + level - lev
|
|
i = 5 - random.below(i + 1)
|
|
result = sick(i, young(lev - level - i, result))
|
|
elif level - lev < 0 and random.below(2) == 1:
|
|
result = sick(level - lev, result)
|
|
elif level - lev < 0:
|
|
result = young(level - lev, result)
|
|
elif level - lev >= 10:
|
|
result = "messianic " + result
|
|
elif level - lev > 5:
|
|
i = 10 - (level - lev)
|
|
i = 5 - random.below(i + 1)
|
|
result = big(i, special(level - lev - i, result))
|
|
elif level - lev > 0 and random.below(2) == 1:
|
|
result = big(level - lev, result)
|
|
elif level - lev > 0:
|
|
result = special(level - lev, result)
|
|
|
|
lev = level
|
|
level = lev * qty
|
|
if not is_definite:
|
|
result = indefinite(result, qty)
|
|
|
|
duration = (2 * 3 * level * 1000) // player_level
|
|
# with a single verb, skip the roll so the stock RNG stream is unchanged
|
|
verb = (
|
|
data.combat_verbs[0]
|
|
if len(data.combat_verbs) == 1
|
|
else cast(str, random.choice(data.combat_verbs))
|
|
)
|
|
description = verb.replace(TARGET_PLACEHOLDER, result)
|
|
return KillTask(description, duration, monster=monster)
|
|
|
|
|
|
@dataclass
|
|
class _Roll:
|
|
stats: Stats
|
|
seed: RngState # generator state before the roll
|
|
after: RngState
|
|
|
|
|
|
class StatsBuilder:
|
|
def __init__(self, rng: Alea | None = None) -> None:
|
|
self._rng = rng or Alea()
|
|
self.history: list[_Roll] = []
|
|
|
|
@property
|
|
def seed(self) -> RngState:
|
|
"""Generator state the current stats were rolled from.
|
|
|
|
Like upstream, a new character's game continues from this state,
|
|
so its first rolls replay the stat roll's numbers.
|
|
"""
|
|
return self.history[-1].seed
|
|
|
|
def roll(self) -> Stats:
|
|
seed = self._rng.state()
|
|
with random.using(self._rng):
|
|
values: dict[StatType, int] = {
|
|
stat: 3 + random.below(6) + random.below(6) + random.below(6)
|
|
for stat in PRIME_STATS
|
|
}
|
|
values[StatType.hp_max] = (
|
|
random.below(8) + values[StatType.condition] // 6
|
|
)
|
|
values[StatType.mp_max] = (
|
|
random.below(8) + values[StatType.intelligence] // 6
|
|
)
|
|
stats = Stats(values)
|
|
self.history.append(_Roll(stats, seed, self._rng.state()))
|
|
return stats
|
|
|
|
def unroll(self) -> Stats:
|
|
if len(self.history) > 1:
|
|
self.history.pop()
|
|
self._rng = Alea.from_state(self.history[-1].after)
|
|
return self.history[-1].stats
|
|
|
|
|
|
def create_player(
|
|
name: str,
|
|
race: Race,
|
|
class_: Class,
|
|
stats: Stats,
|
|
seed: RngState | None = None,
|
|
) -> Player:
|
|
player = Player(
|
|
birthday=datetime.datetime.now(),
|
|
name=name,
|
|
race=race,
|
|
class_=class_,
|
|
stats=stats,
|
|
rng=Alea.from_state(seed) if seed is not None else None,
|
|
)
|
|
return player
|