diff --git a/docs/TEMPLE_SERVICES.md b/docs/TEMPLE_SERVICES.md new file mode 100644 index 000000000..6fb81e529 --- /dev/null +++ b/docs/TEMPLE_SERVICES.md @@ -0,0 +1,116 @@ +# Temple recovery pricing + +Temple recovery uses an explicit service rank and a deterministic quote. NPC +combat level is inventory evidence, not priest proficiency: the 27 providers +range from effective combat level 1 to 115 and include guards, an ogre, a +vampire, a raas, witches, and other non-priest archetypes. + +## Affordability evidence and newcomer policy + +The Incuna start gives no currency. An early quest outcome gives 50 copper and +its combat outcome gives 15 silver. Before this policy, level-1 recovery cost +5 silver 90 copper for depletion, 5 silver for poison, 10 silver for disease, +30 silver for curse removal, and 50 silver for damnation removal. Those fixed +fees could therefore turn an early condition into a progression deadlock. + +Historically, depletion, disease, and poison treatment were free below level +3. Classic now suppresses normal death depletion through character level 3. +The policy aligns those safeguards: patients at or below level 3 receive free +depletion, disease, and poison treatment when condition difficulty is at most +5. Curse and damnation removal remain paid because the historical grace +deliberately excluded inventory curses. A harder condition is never subsidized +solely because its carrier is new; it receives the normal quote and still +requires a capable provider. + +## Provider capability + +The central registry identifies each provider by its stable authored map path +and NPC name, and maps that pair to one reviewed service rank: + +| Rank | Role | Providers | +| ---: | --- | --- | +| 20 | Community | Brelend Lee, Manzom, Telath, Marcus Stephen, Thumron, Helga, Eshorem, Oxa, Jania, Merithax, Ugthar, Morvarm, Cthisss, Alcrom, Murfar | +| 40 | Regional | Hivro Holygauntlet, Sakura, Ami, Saruthar, Celach Dawson, Filbreena Ulass | +| 60 | Senior | Conuld Burch, Friar Marcus, Talrain, Traba Jainkoaren, Gwenty | +| 100 | High priest | Archbishop Theodorus III | + +The content validator discovers standard, special, and quest-gated temple +interfaces and requires those 27 authored identities to match the registry exactly. +Combat-level outliers such as level-1 Manzom (service rank 20), level-115 +Talrain (rank 60), and level-68 Archbishop Theodorus III (rank 100) prove that +the capability input is independent. + +Condition difficulty is the highest disease, poison, cursed-item, or +damned-item level within that native spell's existing scope. Remove damnation +also sees ordinary curses because Classic's spell already handles both; this +policy does not expand it. Depletion difficulty is the greater of its object level and +five times the total depleted stat points. A provider refuses a condition when +difficulty exceeds service rank. During the synchronous spell call, the NPC's +effective level is temporarily set to its service rank and restored in a +`finally` block, so Classic's disease and curse capability checks use the same +reviewed input as the quote. + +## Full-price curve + +For paid care, the unrounded copper price is: + +```text +base + patient_level * patient_factor + + condition_count * condition_factor + + difficulty * difficulty_factor + + depletion_severity * severity_factor +``` + +An easy case treated by a provider above the required rank receives a +capability discount of one percent per two surplus ranks, capped at 30%. The +discounted result is rounded down to 10 copper, then clamped to the service +minimum and maximum. + +| Service | Base | Patient | Condition | Difficulty | Severity | Minimum | Maximum | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Remove depletion | 200 | 45 | 75 | 10 | 100 | 100 | 12,000 | +| Cure disease | 300 | 50 | 100 | 20 | 0 | 100 | 15,000 | +| Cure poison | 150 | 35 | 75 | 15 | 0 | 100 | 8,000 | +| Remove curse | 750 | 60 | 150 | 20 | 0 | 100 | 18,000 | +| Remove damnation | 1,250 | 80 | 200 | 30 | 0 | 100 | 25,000 | + +Examples, all in copper: + +| Case | Rank | Quote | +| --- | ---: | ---: | +| Level-3 patient, difficulty-5 poison | 20 | free | +| Level-4 patient, difficulty-4 poison | 20 | 390 | +| Level-4 patient, one-point depletion (difficulty 5) | 20 | 560 | +| Level-1 patient, difficulty-1 curse | 20 | 890 | +| Level-20 patient, difficulty-20 disease | 20 | 1,800 | +| Same disease and patient | 100 | 1,260 | +| Level-100 patient, difficulty-100 disease | 100 | 7,400 | + +Patient level, condition count, difficulty, and depletion severity all make an +otherwise equivalent quote nondecreasing. Greater surplus provider capability +can only reduce the quote. Integer arithmetic, explicit rounding, and bounds +make the result reproducible. + +## Confirmation and settlement + +Preview and confirmation both use the same quote function. The confirmation +link carries the service, price, count, difficulty, severity, provider rank, +and a digest of condition evidence. Confirmation re-snapshots and requotes; a +changed token produces a new preview and no cast or payment. + +The transaction checks funds before casting, then compares synchronous pre- +and postconditions before collecting payment: + +- no condition: refuse, do not cast, and do not charge; +- insufficient funds: do not cast and do not charge; +- total failure: report failure and do not charge; +- full success: charge exactly the displayed quote; +- partial success: identify the remaining condition and charge exactly the + displayed quote, because the provider was prequalified for the complete + snapshot and delivered beneficial treatment; +- repeated confirmation: the cured condition no longer exists, so do not cast + or charge. + +The individual `remove depletion`, `cure disease`, `cure poison`, `remove +curse`, and `remove damnation` spells remain separate. This policy does not +change `restoration` or broaden any spell's scope. diff --git a/maps/python/Temple.py b/maps/python/Temple.py index 9afcdab58..7dfe840b5 100644 --- a/maps/python/Temple.py +++ b/maps/python/Temple.py @@ -1,42 +1,23 @@ """Temple.py: Implements temple-related functions.""" -from collections import OrderedDict - from Atrinik import * from Interface import InterfaceBuilder - - -temple_services = OrderedDict(( - ("remove depletion", [ - lambda activator: activator.level / 2 * 130 + 525, - "Removal of depletion", - "Depletion most commonly occurs when you die, and it drains some of your stat attributes. In order to get them back, [green]remove depletion[/green] prayer can be used. I can cast this prayer on you, if you wish.", - ]), - ("remove curse", [ - 3000, - "Removal of curse from all cursed items", - "I will remove any curse from all cursed items in your inventory. Note that the curse may still come back, if the item is permanently cursed...", - ]), - ("remove damnation", [ - 5000, - "Removal of damnation from all damned items", - "I will remove any damnation from all damned items in your inventory. Note that the damnation may still come back, if the item is permanently damned...", - ]), - ("cure disease", [ - 1000, - "Curing of disease", - "I can attempt to cure any disease that is troubling you.", - ]), - ("cure poison", [ - 500, - "Curing of poison", - "I can heal your poisoning, if you wish.", - ]), - ("food", [ - 0, - "Spare bit of food", - ]), -)) +from TempleServices import ( + SERVICES, + execute_treatment, + provider_for, + quote as service_quote, + snapshot as service_snapshot, +) + + +service_descriptions = { + "remove depletion": "Depletion most commonly occurs when you die, and it drains some of your stat attributes. I can restore them with a remove depletion prayer.", + "remove curse": "I will remove ordinary curses from cursed items in your inventory. A permanent curse may return.", + "remove damnation": "I will remove damnation from damned items in your inventory. A permanent damnation may return.", + "cure disease": "I can cure diseases that are within my service training.", + "cure poison": "I can cleanse poison that is within my service training.", +} class Temple(InterfaceBuilder): """Temple interface builder.""" @@ -49,69 +30,182 @@ class Temple(InterfaceBuilder): def subdialog_services(self): """Show the available temple services.""" - for service in temple_services: - self.add_link(temple_services[service][1], dest = service) + for service in SERVICES.values(): + self.add_link(service.title, dest=service.spell) - self.add_link("Tell me about {}.".format(self.temple_name), dest = self.temple_name) + self.add_link("Spare bit of food", dest="food") + self.add_link("Tell me about {}.".format(self.temple_name), dest=self.temple_name) if self.enemy_temple_name: - self.add_link("Tell me about {}.".format(self.enemy_temple_name), dest = self.enemy_temple_name) + self.add_link( + "Tell me about {}.".format(self.enemy_temple_name), + dest=self.enemy_temple_name, + ) def dialog_hello(self): """Default hello dialog handler.""" - self.add_msg("Welcome to the church of {god}. I am {npc.name}, a devoted servant of {god}.", god = self.temple_name) + self.add_msg( + "Welcome to the church of {god}. I am {npc.name}, a devoted servant of {god}.", + god=self.temple_name, + ) if self.enemy_temple_name: - self.add_msg("Beware that followers of {enemy_god} are not welcome here.", enemy_god = self.enemy_temple_name) + self.add_msg( + "Beware that followers of {enemy_god} are not welcome here.", + enemy_god=self.enemy_temple_name, + ) self.add_msg("I can offer you the following services.") self.subdialog_services() + def _provider(self): + return provider_for(self._npc.name, self._npc.map.path) + + def _snapshot(self, service_name): + return service_snapshot(service_name, self._activator.inv, Type.DISEASE) + + def _current_quote(self, service_name): + provider = self._provider() + if provider is None: + self.add_msg( + "My temple-service training is not configured, so I cannot safely offer this treatment." + ) + return None + + condition = self._snapshot(service_name) + if condition.count == 0: + self.add_msg("You do not have a condition this service can treat.") + return None + + try: + current = service_quote( + service_name, + self._activator.level, + provider.service_rank, + condition, + ) + except ValueError: + self.add_msg( + "This condition has difficulty {difficulty}, but my service rank is only {rank}. I cannot reliably treat it, so I will not cast or charge you.", + difficulty=condition.difficulty, + rank=provider.service_rank, + ) + return None + + return provider, condition, current + + def _show_quote(self, current): + if current.free: + self.add_msg( + "Newcomer care covers this difficulty-{difficulty} treatment. There is no charge.", + difficulty=current.difficulty, + ) + else: + self.add_msg( + "This difficulty-{difficulty} treatment will cost {cost}.", + difficulty=current.difficulty, + cost=CostString(current.cost), + ) + self.add_link( + "Confirm treatment", + dest="buy {}|{}".format(current.service, current.token), + ) + + def _cast(self, provider, service_name): + old_level = self._npc.level + try: + self._npc.level = provider.service_rank + spell = GetArchetype( + "spell_" + service_name.replace(" ", "_") + ).clone.sp + self._npc.Cast(spell, self._activator) + finally: + self._npc.level = old_level + + def _confirm(self, service_name, presented_token, provider, before, current): + preview = current._replace(token=presented_token) + result = execute_treatment( + preview, + current, + self._activator.GetMoney(), + before, + lambda: self._cast(provider, service_name), + lambda: self._snapshot(service_name), + self._activator.PayAmount, + ) + if result.outcome == "drift": + self.add_msg( + "Your condition or quote changed before confirmation. I have not cast or charged you; please review the new quote." + ) + self._show_quote(current) + elif result.outcome == "insufficient-funds": + self.add_msg( + "You do not have enough money. I have not cast or charged you." + ) + elif result.outcome == "failure": + self.add_msg("The prayer had no effect. You are not charged.") + elif result.outcome == "payment-failure": + self.add_msg( + "The treatment had an effect, but payment could not be collected. You are not charged." + ) + else: + if result.outcome == "partial": + self.add_msg( + "The prayer helped, but some of the condition remains. The confirmed partial-success policy applies." + ) + else: + self.add_msg("The treatment succeeds.") + if result.charged: + self.add_msg( + "You pay {cost}.", + cost=CostString(result.charged), + color=COLOR_YELLOW, + ) + else: + self.add_msg("No payment is due under newcomer care.") + def dialog(self, msg): """Handle services and speaking about particular gods.""" if msg == self.temple_name: self.add_msg(self.temple_desc) - elif msg == self.enemy_temple_name: + return + if msg == self.enemy_temple_name: self.add_msg(self.enemy_temple_desc) - else: - if msg.startswith("buy "): - msg = msg[4:] - is_buy = True + return + if msg == "food": + if self._activator.food < 500: + self._activator.food = 500 + self.add_msg("Your stomach is filled again.") else: - is_buy = False + self.add_msg("You don't look very hungry to me...") + return - service = temple_services.get(msg) - - if not service: + presented_token = None + if msg.startswith("buy "): + service_name, separator, presented_token = msg[4:].partition("|") + if not separator: return - - if msg == "food": - if self._activator.food < 500: - self._activator.food = 500 - self.add_msg("Your stomach is filled again.") - else: - self.add_msg("You don't look very hungry to me...") - else: - self.add_msg("[title]{service[1]}[/title]", service = service) - cost = service[0] - if not isinstance(cost, int): - cost = int(cost(self._activator)) - - if not is_buy: - self.add_msg(service[2]) - self.add_msg("This will cost you {cost}.", cost = CostString(cost)) - self.add_link("Confirm", dest = "buy " + msg) - else: - if self._activator.PayAmount(cost): - if cost: - self.add_msg("You pay {cost}.", cost = CostString(cost), color = COLOR_YELLOW) - - self.add_msg("Okay, I will cast [green]{origmsg}[/green] on you now.", origmsg = msg) - self._npc.Cast(GetArchetype("spell_" + msg.replace(" ", "_")).clone.sp, self._activator) - else: - self.add_msg("You do not have enough money...") + else: + service_name = msg + + service = SERVICES.get(service_name) + if service is None: + return + + self.add_msg("[title]{title}[/title]", title=service.title) + self.add_msg(service_descriptions[service_name]) + quoted = self._current_quote(service_name) + if quoted is None: + return + provider, before, current = quoted + if presented_token is None: + self._show_quote(current) + else: + self._confirm( + service_name, presented_token, provider, before, current + ) class TempleGrunhilde(Temple): """Grunhilde temple.""" diff --git a/maps/python/TempleServices.py b/maps/python/TempleServices.py new file mode 100644 index 000000000..d2126e0d0 --- /dev/null +++ b/maps/python/TempleServices.py @@ -0,0 +1,257 @@ +"""Pure policy for Classic temple recovery quotes and provider capability.""" + +from collections import OrderedDict, namedtuple +import hashlib + + +Service = namedtuple( + "Service", + ( + "title spell base patient condition difficulty severity minimum maximum " + "newcomer_essential" + ), +) +Provider = namedtuple( + "Provider", "name map_path interface combat_level service_rank" +) +Condition = namedtuple("Condition", "count difficulty severity fingerprint") +Quote = namedtuple( + "Quote", + "service cost count difficulty severity provider_rank free discount token", +) +Settlement = namedtuple("Settlement", "outcome charged after") + + +NEWCOMER_MAX_LEVEL = 3 +NEWCOMER_MAX_DIFFICULTY = 5 +ROUNDING_UNIT = 10 +MAX_CAPABILITY_DISCOUNT = 30 + + +SERVICES = OrderedDict(( + ("remove depletion", Service( + "Removal of depletion", "remove depletion", + 200, 45, 75, 10, 100, 100, 12000, True, + )), + ("remove curse", Service( + "Removal of curse from all cursed items", "remove curse", + 750, 60, 150, 20, 0, 100, 18000, False, + )), + ("remove damnation", Service( + "Removal of damnation from all damned items", "remove damnation", + 1250, 80, 200, 30, 0, 100, 25000, False, + )), + ("cure disease", Service( + "Curing of disease", "cure disease", + 300, 50, 100, 20, 0, 100, 15000, True, + )), + ("cure poison", Service( + "Curing of poison", "cure poison", + 150, 35, 75, 15, 0, 100, 8000, True, + )), +)) + + +def _provider(name, map_path, interface, combat_level, service_rank): + return Provider(name, map_path, interface, combat_level, service_rank) + + +PROVIDERS = OrderedDict(( + ("brelend-lee", _provider("Brelend Lee", "maps/shattered_islands/world_4_84", "quests/lost_memories/quest.xml", 20, 20)), + ("manzom", _provider("Manzom", "maps/shattered_islands/world_13_4", "temples/elathiel.xml", 1, 20)), + ("telath", _provider("Telath", "maps/shattered_islands/world_8_78", "temples/elathiel.xml", 20, 20)), + ("marcus-stephen", _provider("Marcus Stephen", "maps/shattered_islands/world_6_42", "temples/elathiel.xml", 22, 20)), + ("thumron", _provider("Thumron", "maps/shattered_islands/world_7_42", "temples/grumthar.xml", 22, 20)), + ("helga", _provider("Helga", "maps/shattered_islands/world_7_43", "temples/grunhilde.xml", 22, 20)), + ("eshorem", _provider("Eshorem", "maps/shattered_islands/world_7_43", "temples/tylowyn.xml", 22, 20)), + ("oxa", _provider("Oxa", "maps/shattered_islands/world_7_44", "temples/shaligar.xml", 22, 20)), + ("jania", _provider("Jania", "maps/shattered_islands/world_7_44", "temples/terria.xml", 24, 20)), + ("merithax", _provider("Merithax", "maps/shattered_islands/world_8_43", "temples/dalosha.xml", 24, 20)), + ("ugthar", _provider("Ugthar", "maps/shattered_islands/world_8_43", "temples/jotarl.xml", 22, 20)), + ("morvarm", _provider("Morvarm", "maps/shattered_islands/world_8_43", "temples/zechna.xml", 25, 20)), + ("cthisss", _provider("Cthisss", "maps/shattered_islands/world_8_43", "temples/rashindel.xml", 25, 20)), + ("alcrom", _provider("Alcrom", "maps/shattered_islands/world_8_44", "temples/rogroth.xml", 28, 20)), + ("murfar", _provider("Murfar", "maps/shattered_islands/world_8_44", "temples/drolaxi.xml", 22, 20)), + ("hivro-holygauntlet", _provider("Hivro Holygauntlet", "maps/shattered_islands/strakewood_island/rockforge/rockforge_a_ab01", "temples/grumthar.xml", 50, 40)), + ("sakura", _provider("Sakura", "maps/shattered_islands/world_0_53", "strakewood_island/centennial/sakura.xml", 30, 40)), + ("ami", _provider("Ami", "maps/shattered_islands/world_-1_52", "strakewood_island/centennial/ami.xml", 30, 40)), + ("saruthar", _provider("Saruthar", "maps/shattered_islands/world_3_58", "temples/elathiel.xml", 35, 40)), + ("celach-dawson", _provider("Celach Dawson", "maps/shattered_islands/world_5_67", "temples/elathiel.xml", 35, 40)), + ("filbreena-ulass", _provider("Filbreena Ulass", "maps/shattered_islands/world_2_48_-3", "temples/dalosha.xml", 37, 40)), + ("conuld-burch", _provider("Conuld Burch", "maps/shattered_islands/world_2_68", "temples/elathiel.xml", 65, 60)), + ("friar-marcus", _provider("Friar Marcus", "maps/shattered_islands/world_6_44_1", "temples/elathiel.xml", 45, 60)), + ("talrain", _provider("Talrain", "maps/shattered_islands/world_9_69", "temples/elathiel.xml", 115, 60)), + ("traba-jainkoaren", _provider("Traba Jainkoaren", "maps/shattered_islands/world_12_62", "temples/elathiel.xml", 115, 60)), + ("gwenty", _provider("Gwenty", "maps/shattered_islands/world_4_53_-1", "quests/fort_sether_illness/quest.xml", 60, 60)), + ("archbishop-theodorus-iii", _provider("Archbishop Theodorus III", "maps/shattered_islands/world_0_41", "temples/elathiel.xml", 68, 100)), +)) + + +def provider_for(name, map_path): + """Resolve one provider by its stable authored map and NPC identity.""" + + map_path = "maps/" + str(map_path).lstrip("/") + matches = [ + provider + for provider in PROVIDERS.values() + if provider.name == name and provider.map_path == map_path + ] + return matches[0] if len(matches) == 1 else None + + +def bounded_cost(raw, discount, minimum, maximum): + """Apply the documented integer discount, rounding, and bounds.""" + + cost = int(raw) * (100 - int(discount)) // 100 + cost = cost // ROUNDING_UNIT * ROUNDING_UNIT + return min(int(maximum), max(int(minimum), cost)) + + +def condition(count, difficulty, severity=0, evidence=()): + """Create a normalized condition snapshot with a stable evidence digest.""" + + count = max(0, int(count)) + difficulty = max(0, int(difficulty)) + severity = max(0, int(severity)) + payload = "\n".join(str(value) for value in evidence).encode("utf-8") + fingerprint = hashlib.sha256(payload).hexdigest()[:16] + return Condition(count, difficulty, severity, fingerprint) + + +def quote(service_name, patient_level, provider_rank, snapshot): + """Return the exact bounded integer quote for one condition snapshot.""" + + service = SERVICES[service_name] + patient_level = max(1, int(patient_level)) + provider_rank = max(1, int(provider_rank)) + if snapshot.count <= 0: + raise ValueError("no applicable condition") + if snapshot.difficulty > provider_rank: + raise ValueError("provider is incapable of reliably treating condition") + + free = ( + service.newcomer_essential + and patient_level <= NEWCOMER_MAX_LEVEL + and snapshot.difficulty <= NEWCOMER_MAX_DIFFICULTY + ) + discount = min( + MAX_CAPABILITY_DISCOUNT, + max(0, (provider_rank - snapshot.difficulty) // 2), + ) + if free: + cost = 0 + else: + raw = ( + service.base + + patient_level * service.patient + + snapshot.count * service.condition + + snapshot.difficulty * service.difficulty + + snapshot.severity * service.severity + ) + cost = bounded_cost( + raw, discount, service.minimum, service.maximum + ) + + token_fields = ( + service_name, + cost, + snapshot.count, + snapshot.difficulty, + snapshot.severity, + provider_rank, + snapshot.fingerprint, + ) + token = ":".join(str(value) for value in token_fields) + return Quote( + service_name, + cost, + snapshot.count, + snapshot.difficulty, + snapshot.severity, + provider_rank, + free, + discount, + token, + ) + + +def treatment_progress(before, after): + """Classify a synchronous pre/postcondition comparison.""" + + before_burden = before.severity or before.count + after_burden = after.severity or after.count + if after_burden >= before_burden: + return "failure" + if after_burden == 0: + return "full" + return "partial" + + +def _archname(obj): + arch = getattr(obj, "arch", None) + return getattr(arch, "name", "") + + +def snapshot(service_name, objects, disease_type): + """Inspect the immediate patient inventory for one spell's condition.""" + + matches = [] + severity = 0 + for obj in objects: + archname = _archname(obj) + if service_name == "remove depletion": + selected = archname == "depletion" + elif service_name == "cure disease": + selected = obj.type == disease_type + elif service_name == "cure poison": + selected = archname == "poisoning" + elif service_name == "remove curse": + selected = bool(obj.f_cursed) + elif service_name == "remove damnation": + selected = bool(obj.f_cursed or obj.f_damned) + else: + raise KeyError(service_name) + if not selected: + continue + + stats = tuple( + int(getattr(obj, name)) + for name in ("Str", "Dex", "Con", "Int", "Pow") + ) if service_name == "remove depletion" else () + if stats: + severity += sum(abs(value) for value in stats if value < 0) + matches.append(( + archname, + getattr(obj, "name", ""), + max(1, int(getattr(obj, "level", 1))), + bool(getattr(obj, "f_cursed", False)), + bool(getattr(obj, "f_damned", False)), + stats, + )) + + if not matches: + return condition(0, 0) + difficulty = max(match[2] for match in matches) + if service_name == "remove depletion": + difficulty = max(difficulty, severity * 5) + return condition( + len(matches), difficulty, severity, + sorted(repr(match) for match in matches), + ) + + +def execute_treatment(preview, current, money, before, cast, resnapshot, pay): + """Execute and settle one synchronous treatment without charging a no-op.""" + + if current.token != preview.token: + return Settlement("drift", 0, before) + if money < current.cost: + return Settlement("insufficient-funds", 0, before) + cast() + after = resnapshot() + outcome = treatment_progress(before, after) + if outcome == "failure": + return Settlement(outcome, 0, after) + if current.cost and not pay(current.cost): + return Settlement("payment-failure", 0, after) + return Settlement(outcome, current.cost, after) diff --git a/maps/python/tests/Temple.py b/maps/python/tests/Temple.py new file mode 100644 index 000000000..2fef88ba7 --- /dev/null +++ b/maps/python/tests/Temple.py @@ -0,0 +1,146 @@ +import unittest + +import Atrinik +from Temple import Temple +import TempleServices +from tests import TestSuite + + +class TempleSuite(TestSuite): + def setUp(self): + super().setUp() + self.npc = activator.map.CreateObject( + "cleric_white_red", activator.x, activator.y + ) + self.provider = None + self.npc.level = 68 + self.created = [] + + def tearDown(self): + for obj in reversed(self.created): + if obj: + obj.Destroy() + if self.npc: + self.npc.Destroy() + super().tearDown() + + def create(self, archname): + obj = activator.CreateObject(archname) + self.created.append(obj) + return obj + + def fund(self): + coin = self.create("goldcoin") + coin.nrof = 1000 + return coin + + def quote(self, service_name): + temple = Temple(activator, self.npc) + if self.provider is not None: + temple._provider = lambda: self.provider + else: + temple._provider = lambda: TempleServices.PROVIDERS[ + "archbishop-theodorus-iii" + ] + provider, before, current = temple._current_quote(service_name) + return temple, provider, before, current + + def test_live_depletion_success_charges_after_effect_and_restores_rank(self): + depletion = self.create("depletion") + depletion.Str = -2 + self.fund() + money = activator.GetMoney() + temple, provider, before, current = self.quote("remove depletion") + + temple._confirm( + "remove depletion", current.token, provider, before, current + ) + + self.assertFalse(depletion) + self.assertEqual(money - activator.GetMoney(), current.cost) + self.assertEqual(self.npc.level, 68) + + money = activator.GetMoney() + repeated = Temple(activator, self.npc) + repeated._provider = lambda: provider + repeated.dialog("buy remove depletion|" + current.token) + self.assertEqual(activator.GetMoney(), money) + self.assertIn("do not have a condition", repeated._msg) + + def test_quote_drift_does_not_cast_or_charge(self): + depletion = self.create("depletion") + depletion.Str = -2 + self.fund() + money = activator.GetMoney() + temple, provider, before, current = self.quote("remove depletion") + depletion.Str = -3 + quoted = temple._current_quote("remove depletion") + + temple._confirm( + "remove depletion", current.token, provider, quoted[1], quoted[2] + ) + + self.assertTrue(depletion) + self.assertEqual(depletion.Str, -3) + self.assertEqual(activator.GetMoney(), money) + self.assertIn("changed before confirmation", temple._msg) + + def test_insufficient_funds_does_not_cast(self): + depletion = self.create("depletion") + depletion.Str = -2 + temple, provider, before, current = self.quote("remove depletion") + self.assertGreater(current.cost, 0) + money = activator.GetMoney() + unaffordable = current._replace(cost=money + 1) + + temple._confirm( + "remove depletion", current.token, provider, before, unaffordable + ) + + self.assertTrue(depletion) + self.assertEqual(activator.GetMoney(), money) + self.assertIn("not have enough money", temple._msg) + + def test_total_failure_is_free_and_partial_success_uses_exact_quote(self): + first = self.create("sword") + first.level = 5 + first.f_cursed = True + second = self.create("dagger") + second.level = 10 + second.f_cursed = True + self.fund() + + temple, provider, before, current = self.quote("remove curse") + money = activator.GetMoney() + temple._cast = lambda provider, service_name: None + temple._confirm("remove curse", current.token, provider, before, current) + self.assertEqual(activator.GetMoney(), money) + self.assertTrue(first.f_cursed) + self.assertTrue(second.f_cursed) + self.assertIn("no effect", temple._msg) + + temple, provider, before, current = self.quote("remove curse") + temple._cast = lambda provider, service_name: setattr( + first, "f_cursed", False + ) + temple._confirm("remove curse", current.token, provider, before, current) + self.assertEqual(money - activator.GetMoney(), current.cost) + self.assertFalse(first.f_cursed) + self.assertTrue(second.f_cursed) + self.assertIn("some of the condition remains", temple._msg) + + def test_incapable_provider_refuses_high_level_curse(self): + self.provider = TempleServices.PROVIDERS["brelend-lee"] + item = self.create("sword") + item.level = 21 + item.f_cursed = True + temple = Temple(activator, self.npc) + temple._provider = lambda: self.provider + + self.assertIsNone(temple._current_quote("remove curse")) + self.assertTrue(item.f_cursed) + self.assertIn("cannot reliably treat", temple._msg) + + +activator = Atrinik.WhoIsActivator() +suites = [unittest.TestLoader().loadTestsFromTestCase(TempleSuite)] diff --git a/maps/python/tests/__init__.py b/maps/python/tests/__init__.py index f56d4e34f..9021ea9c6 100644 --- a/maps/python/tests/__init__.py +++ b/maps/python/tests/__init__.py @@ -21,6 +21,7 @@ def run(): import tests.Interface import tests.LostMemoriesArrival import tests.QuestManager + import tests.Temple all_suites = [] all_suites += tests.Atrinik_tests.Archetype.suites @@ -34,6 +35,7 @@ def run(): all_suites += tests.Interface.suites all_suites += tests.LostMemoriesArrival.suites all_suites += tests.QuestManager.suites + all_suites += tests.Temple.suites old_all_tests = unittest.TestSuite(all_suites) unit_test = Atrinik.GetSettings()["plugin_unit_test"] diff --git a/tools/temple_services.py b/tools/temple_services.py new file mode 100644 index 000000000..350706821 --- /dev/null +++ b/tools/temple_services.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Audit every standard and special Classic temple service provider.""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +import sys +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).parents[1].resolve() +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "maps" / "python")) + +import TempleServices +from tools.content_core import parse_bytes + + +def interface_rules(root: Path): + rules = defaultdict(set) + interface_root = root / "maps" / "interfaces" + for path in sorted(interface_root.rglob("*.xml")): + relative = path.relative_to(root / "maps").as_posix() + key = "/" + relative + document = ET.parse(path) + for element in document.getroot().iter("interface"): + if (element.get("inherit") or "").startswith("Temple.Temple"): + rules[key].add(element.get("npc")) + return dict(rules) + + +def map_paths(root: Path, interfaces): + needles = tuple( + ("race " + interface + "\n").encode("utf-8") + for interface in interfaces + ) + for path in sorted((root / "maps").rglob("*")): + if not path.is_file() or path.is_symlink(): + continue + source = path.read_bytes() + if source.startswith(b"arch map\n") and any( + needle in source for needle in needles + ): + yield path, source + + +def inventory(root: Path): + root = root.resolve(strict=True) + rules = interface_rules(root) + rows = [] + for path, source in map_paths(root, rules): + relative = path.relative_to(root).as_posix() + document = parse_bytes( + source, + path=relative, + format_name="map", + schema_root=root, + ) + for event in document.objects: + interface = event.last_value("race") + if interface not in rules or event.parent_handle is None: + continue + parent = document.node(event.parent_handle) + name = parent.last_value("name", parent.name) + npcs = rules[interface] + if None not in npcs and name not in npcs: + continue + provider_id = next(( + provider_id + for provider_id, provider in TempleServices.PROVIDERS.items() + if provider.name == name and provider.map_path == relative + ), None) + rows.append({ + "provider_id": provider_id, + "name": name, + "map_path": relative, + "interface": interface.removeprefix("/interfaces/"), + "combat_level": int(parent.last_value("level", "1")), + "line": parent.opener_span.line, + }) + return rows + + +def validate(rows, providers): + errors = [] + by_id = defaultdict(list) + for row in rows: + if row["provider_id"] is None: + errors.append( + "{}:{}: {} has no provider registry entry".format( + row["map_path"], row["line"], row["name"] + ) + ) + continue + by_id[row["provider_id"]].append(row) + + expected_ids = set(providers) + actual_ids = set(by_id) + for provider_id in sorted(expected_ids - actual_ids): + errors.append("registered provider is not authored: {}".format(provider_id)) + for provider_id in sorted(actual_ids - expected_ids): + errors.append("authored provider is not registered: {}".format(provider_id)) + for provider_id in sorted(expected_ids & actual_ids): + authored = by_id[provider_id] + if len(authored) != 1: + errors.append( + "provider id {} is authored {} times".format( + provider_id, len(authored) + ) + ) + continue + row = authored[0] + provider = providers[provider_id] + expected = { + "name": provider.name, + "map_path": provider.map_path, + "interface": provider.interface, + "combat_level": provider.combat_level, + } + for field, value in expected.items(): + if row[field] != value: + errors.append( + "{} {} mismatch: authored {!r}, registered {!r}".format( + provider_id, field, row[field], value + ) + ) + if provider.service_rank not in (20, 40, 60, 100): + errors.append( + "{} has unsupported service rank {}".format( + provider_id, provider.service_rank + ) + ) + return errors + + +def audit(root: Path): + rows = inventory(root) + errors = validate(rows, TempleServices.PROVIDERS) + return { + "providers": len(rows), + "registered": len(TempleServices.PROVIDERS), + "errors": errors, + } + + +def main(argv=None): + root = Path(argv[0] if argv else ".") + report = audit(root) + if report["errors"]: + for error in report["errors"]: + print(error, file=sys.stderr) + return 1 + print( + "Temple services: {} providers match the explicit capability registry.".format( + report["providers"] + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/tests/test_temple_services.py b/tools/tests/test_temple_services.py new file mode 100644 index 000000000..fa4c8f48e --- /dev/null +++ b/tools/tests/test_temple_services.py @@ -0,0 +1,205 @@ +from pathlib import Path +import sys +import unittest + + +ROOT = Path(__file__).parents[2].resolve() +sys.path.insert(0, str(ROOT / "maps" / "python")) + +import TempleServices + + +class TempleServicesTest(unittest.TestCase): + def snapshot(self, count=1, difficulty=1, severity=0, evidence=("x",)): + return TempleServices.condition(count, difficulty, severity, evidence) + + def test_provider_inventory_is_explicit_and_not_combat_level(self): + self.assertEqual(len(TempleServices.PROVIDERS), 27) + self.assertEqual( + {provider.service_rank for provider in TempleServices.PROVIDERS.values()}, + {20, 40, 60, 100}, + ) + self.assertEqual(TempleServices.PROVIDERS["manzom"].combat_level, 1) + self.assertEqual(TempleServices.PROVIDERS["manzom"].service_rank, 20) + self.assertEqual(TempleServices.PROVIDERS["talrain"].combat_level, 115) + self.assertEqual(TempleServices.PROVIDERS["talrain"].service_rank, 60) + self.assertEqual( + TempleServices.provider_for( + "Talrain", "/shattered_islands/world_9_69" + ), + TempleServices.PROVIDERS["talrain"], + ) + self.assertIsNone( + TempleServices.provider_for( + "Talrain", "/shattered_islands/world_0_41" + ) + ) + + def test_newcomer_boundary_and_difficulty_guard(self): + easy = self.snapshot(difficulty=5) + self.assertEqual(TempleServices.quote("cure disease", 2, 20, easy).cost, 0) + self.assertEqual(TempleServices.quote("cure disease", 3, 20, easy).cost, 0) + self.assertGreater(TempleServices.quote("cure disease", 4, 20, easy).cost, 0) + hard = self.snapshot(difficulty=6) + self.assertGreater(TempleServices.quote("cure disease", 3, 20, hard).cost, 0) + self.assertGreater(TempleServices.quote("remove curse", 1, 20, easy).cost, 0) + + def test_weak_equal_and_strong_provider_quotes(self): + snapshot = self.snapshot(difficulty=20) + with self.assertRaisesRegex(ValueError, "incapable"): + TempleServices.quote("cure disease", 20, 19, snapshot) + equal = TempleServices.quote("cure disease", 20, 20, snapshot) + strong = TempleServices.quote("cure disease", 20, 100, snapshot) + self.assertEqual(equal.cost, 1800) + self.assertEqual(strong.cost, 1260) + self.assertLess(strong.cost, equal.cost) + + def test_need_is_monotonic(self): + lower_patient = TempleServices.quote( + "cure poison", 4, 40, self.snapshot(difficulty=4) + ) + higher_patient = TempleServices.quote( + "cure poison", 10, 40, self.snapshot(difficulty=4) + ) + harder_condition = TempleServices.quote( + "cure poison", 10, 40, self.snapshot(difficulty=20) + ) + self.assertLess(lower_patient.cost, higher_patient.cost) + self.assertLess(higher_patient.cost, harder_condition.cost) + + def test_rounding_minimum_and_cap(self): + self.assertEqual( + TempleServices.bounded_cost(1, 0, 100, 8000), 100 + ) + rounded = TempleServices.quote( + "cure poison", 4, 20, self.snapshot(difficulty=4) + ) + self.assertEqual(rounded.cost, 390) + self.assertEqual(rounded.cost % TempleServices.ROUNDING_UNIT, 0) + capped = TempleServices.quote( + "cure poison", 999, 1000, self.snapshot(100, 100) + ) + self.assertEqual(capped.cost, TempleServices.SERVICES["cure poison"].maximum) + + def test_preview_token_captures_all_quote_inputs(self): + first = TempleServices.quote( + "remove depletion", 4, 20, + self.snapshot(difficulty=5, severity=1, evidence=("str:-1",)), + ) + same = TempleServices.quote( + "remove depletion", 4, 20, + self.snapshot(difficulty=5, severity=1, evidence=("str:-1",)), + ) + changed = TempleServices.quote( + "remove depletion", 4, 20, + self.snapshot(difficulty=10, severity=2, evidence=("str:-2",)), + ) + self.assertEqual(first.token, same.token) + self.assertNotEqual(first.token, changed.token) + + def test_no_condition_is_refused(self): + with self.assertRaisesRegex(ValueError, "no applicable"): + TempleServices.quote("cure poison", 4, 20, self.snapshot(0, 0)) + + def test_treatment_progress(self): + before = self.snapshot(count=2, difficulty=10, evidence=("a", "b")) + self.assertEqual(TempleServices.treatment_progress(before, before), "failure") + self.assertEqual( + TempleServices.treatment_progress( + before, self.snapshot(count=1, difficulty=5, evidence=("b",)) + ), + "partial", + ) + self.assertEqual( + TempleServices.treatment_progress(before, self.snapshot(0, 0)), + "full", + ) + + def test_condition_snapshots_match_spell_boundaries(self): + class Arch: + def __init__(self, name): + self.name = name + + class Obj: + def __init__(self, arch, obj_type=0, level=1, cursed=False, + damned=False, stats=(0, 0, 0, 0, 0)): + self.arch = Arch(arch) + self.name = arch + self.type = obj_type + self.level = level + self.f_cursed = cursed + self.f_damned = damned + for name, value in zip( + ("Str", "Dex", "Con", "Int", "Pow"), stats + ): + setattr(self, name, value) + + objects = [ + Obj("depletion", stats=(-1, 0, -2, 0, 0)), + Obj("plague", obj_type=99, level=30), + Obj("poisoning", level=8), + Obj("ring", level=12, cursed=True), + Obj("sword", level=18, cursed=True, damned=True), + ] + depletion = TempleServices.snapshot("remove depletion", objects, 99) + self.assertEqual( + (depletion.count, depletion.severity, depletion.difficulty), + (1, 3, 15), + ) + self.assertEqual(TempleServices.snapshot("cure disease", objects, 99).difficulty, 30) + self.assertEqual(TempleServices.snapshot("cure poison", objects, 99).difficulty, 8) + self.assertEqual(TempleServices.snapshot("remove curse", objects, 99).difficulty, 18) + self.assertEqual(TempleServices.snapshot("remove damnation", objects, 99).difficulty, 18) + + def test_execute_treatment_covers_drift_money_and_settlement(self): + before = self.snapshot(count=2, difficulty=10, evidence=("a", "b")) + preview = TempleServices.quote("cure disease", 20, 20, before) + drifted = TempleServices.quote( + "cure disease", 20, 20, + self.snapshot(count=2, difficulty=11, evidence=("a", "changed")), + ) + events = [] + result = TempleServices.execute_treatment( + preview, drifted, 10000, before, + lambda: events.append("cast"), lambda: before, + lambda amount: events.append(("pay", amount)), + ) + self.assertEqual(result.outcome, "drift") + self.assertEqual(events, []) + + result = TempleServices.execute_treatment( + preview, preview, preview.cost - 1, before, + lambda: events.append("cast"), lambda: before, lambda amount: True, + ) + self.assertEqual(result.outcome, "insufficient-funds") + self.assertEqual(events, []) + + result = TempleServices.execute_treatment( + preview, preview, preview.cost, before, + lambda: events.append("cast"), lambda: before, + lambda amount: events.append(("pay", amount)), + ) + self.assertEqual(result.outcome, "failure") + self.assertEqual(result.charged, 0) + self.assertEqual(events, ["cast"]) + + after = self.snapshot(count=1, difficulty=5, evidence=("b",)) + result = TempleServices.execute_treatment( + preview, preview, preview.cost, before, + lambda: events.append("cast"), lambda: after, + lambda amount: events.append(("pay", amount)) or True, + ) + self.assertEqual(result.outcome, "partial") + self.assertEqual(result.charged, preview.cost) + self.assertEqual(events[-2:], ["cast", ("pay", preview.cost)]) + + result = TempleServices.execute_treatment( + preview, preview, preview.cost, before, + lambda: None, lambda: self.snapshot(0, 0), lambda amount: True, + ) + self.assertEqual(result.outcome, "full") + self.assertEqual(result.charged, preview.cost) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate.py b/tools/validate.py index 72cc2665d..e97b56782 100755 --- a/tools/validate.py +++ b/tools/validate.py @@ -69,6 +69,7 @@ def main() -> int: "tools.tests.test_release_line_parity", "tools.tests.test_pr_metadata", "tools.tests.test_python_commands", + "tools.tests.test_temple_services", ], cwd=ROOT, check=True, @@ -76,6 +77,11 @@ def main() -> int: plural_report = audit_archetype_plurals( ROOT, load_manifest(ROOT / MANIFEST_PATH) ) + subprocess.run( + [sys.executable, str(ROOT / "tools" / "temple_services.py")], + cwd=ROOT, + check=True, + ) print( "Archetype plurals: {} canonical definitions complete; {} multipart or " "nested objects excluded.".format(