From 68e4ecd1a923a9769a2f7272a798d1e2038bb07f Mon Sep 17 00:00:00 2001 From: Ken Moriarty Date: Mon, 25 Mar 2024 18:38:13 -0600 Subject: [PATCH] Support hidden features. --- src/camp/engine/rules/base_engine.py | 40 +++++++++++++++---- src/camp/engine/rules/base_models.py | 3 ++ .../controllers/attribute_controllers.py | 13 ++++++ .../controllers/character_controller.py | 9 +++++ .../tempest/controllers/feature_controller.py | 16 ++++++++ .../controllers/religion_controller.py | 2 + src/camp/engine/rules/tempest/defs.py | 18 ++++++++- .../tempest_test/perks/secret-perk.yaml | 5 +++ tests/tempest_test/test_character.py | 24 +++++++++++ 9 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 tests/rulesets/tempest_test/perks/secret-perk.yaml diff --git a/src/camp/engine/rules/base_engine.py b/src/camp/engine/rules/base_engine.py index e84fe1a..f7e6666 100644 --- a/src/camp/engine/rules/base_engine.py +++ b/src/camp/engine/rules/base_engine.py @@ -280,24 +280,30 @@ def attribute_controller( if controller := self._attribute_controllers.get(expr.full_id): return controller - attr = self.engine.attribute_map.get(expr.prop) - if attr is not None: + if attr := self.engine.attribute_map.get(expr.prop): # There are a few different ways an attribute might be stored or computed. if attr.scoped: # Scoped attributes are never stored on the character controller. pass else: - attr_value = getattr(self, attr.property_id, None) - if attr_value is not None: - if isinstance(attr_value, AttributeController): - controller = attr_value - else: - controller = SimpleAttributeWrapper(expr.full_id, self) + controller = self._make_attribute_controller(expr) + if controller: self._attribute_controllers[expr.full_id] = controller return controller raise ValueError(f"Attribute {expr.full_id} not found.") + def _make_attribute_controller( + self, expr: base_models.PropExpression + ) -> AttributeController | None: + attr_value = getattr(self, expr.prop, None) + if attr_value is not None: + if isinstance(attr_value, AttributeController): + return attr_value + else: + return SimpleAttributeWrapper(expr.full_id, self) + return None + def get(self, expr: str | base_models.PropExpression) -> int: """Retrieve the value of an arbitrary property (feature, attribute, etc). @@ -520,6 +526,8 @@ def __init__(self, full_id: str, character: CharacterController): self.character = character def display_name(self) -> str: + if self.hidden: + return "???" name = self.character.display_name(self.expression.prop) if self.option: name += f" [{self.option}]" @@ -546,6 +554,10 @@ def option(self) -> str | None: def max_value(self) -> int: return self.value + @property + def hidden(self) -> bool: + return False + def get(self, expr: str | base_models.PropExpression) -> int: expr = base_models.PropExpression.parse(expr) prefix, expr = expr.pop() @@ -744,12 +756,16 @@ def min_value(self) -> int | None: @property def description(self) -> str | None: + if self.hidden: + return "???" if self.option and (descr := self.option_description(self.option)): return f"""{self.definition.description}\n\n## {self.option}\n\n{descr}""" return self.definition.description @property def short_description(self) -> str | None: + if self.hidden: + return "???" if self.definition.short_description: return self.definition.short_description if self.description: @@ -760,12 +776,16 @@ def short_description(self) -> str | None: return None def option_description(self, option: str) -> str | None: + if self.hidden: + return "???" if option_def := self.option_def: if descriptions := option_def.descriptions: return descriptions.get(option) return None def describe_option(self, option: str) -> str: + if self.hidden: + return "???" if descr := self.option_description(option): return f"{option}: {descr}" return option @@ -783,6 +803,8 @@ def max_ranks(self) -> int: @property def type_name(self) -> str: + if self.hidden: + return "???" return self.character.display_name(self.feature_type) @property @@ -831,6 +853,8 @@ def purchase_cost_string(self) -> str | None: @property def category(self) -> str | None: + if self.hidden: + return "???" return self.definition.category @property diff --git a/src/camp/engine/rules/base_models.py b/src/camp/engine/rules/base_models.py index c282f71..89ddb58 100644 --- a/src/camp/engine/rules/base_models.py +++ b/src/camp/engine/rules/base_models.py @@ -466,6 +466,8 @@ class BaseFeatureDef(BaseModel): to specify the type literally, which can aid the parser in identifying what model to use. Ex: type: Literal['subtypetag'] = 'subtypetag' + hidden: If True, this feature will not findable or viewable in any context, unless + the active character has it or qualifies for it. requires: Requirements that must be met for the feature to be added. Note that requirements are interpreted as "always on" - should the character stop meeting the requirement, the character will no longer signal as valid. @@ -488,6 +490,7 @@ class BaseFeatureDef(BaseModel): id: str name: str type: str + hidden: bool = False parent: str | None = None supersedes: str | None = None category: str | None = None diff --git a/src/camp/engine/rules/tempest/controllers/attribute_controllers.py b/src/camp/engine/rules/tempest/controllers/attribute_controllers.py index 2892b2c..3ba01cc 100644 --- a/src/camp/engine/rules/tempest/controllers/attribute_controllers.py +++ b/src/camp/engine/rules/tempest/controllers/attribute_controllers.py @@ -188,3 +188,16 @@ def costuming(self) -> int: @property def value(self) -> int: return self.awarded_bp - self.advantage_cost_bp + + +class FlagController(AttributeController): + character: base_engine.CharacterController + + @property + def value(self) -> int: + if option := self.option: + v = self.character.model.metadata.flags.get(option, None) + if isinstance(v, (int, float)): + return int(v) + return bool(v) + return 0 diff --git a/src/camp/engine/rules/tempest/controllers/character_controller.py b/src/camp/engine/rules/tempest/controllers/character_controller.py index 4a84acb..7409274 100644 --- a/src/camp/engine/rules/tempest/controllers/character_controller.py +++ b/src/camp/engine/rules/tempest/controllers/character_controller.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from functools import cached_property +from typing import override from camp.engine.rules import base_engine from camp.engine.rules.base_models import ChoiceMutation @@ -562,6 +563,14 @@ def issues(self) -> list[Issue]: return issues + @override + def _make_attribute_controller( + self, expr: PropExpression + ) -> attribute_controllers.AttributeController: + if expr.prop == "flag": + return attribute_controllers.FlagController(expr.full_id, self) + return super()._make_attribute_controller(expr) + def clear_caches(self): super().clear_caches() self._features = {} diff --git a/src/camp/engine/rules/tempest/controllers/feature_controller.py b/src/camp/engine/rules/tempest/controllers/feature_controller.py index 02ac5dd..7b11399 100644 --- a/src/camp/engine/rules/tempest/controllers/feature_controller.py +++ b/src/camp/engine/rules/tempest/controllers/feature_controller.py @@ -84,6 +84,10 @@ def subfeatures_available(self) -> list[FeatureController]: def internal(self) -> bool: return self.feature_type in _SUBFEATURE_TYPES + @cached_property + def hidden(self) -> bool: + return self.definition.hidden and not self.meets_requirements + @property def parent(self) -> FeatureController | None: parent = super().parent @@ -130,6 +134,8 @@ def cost(self) -> int: return self.cost_for(self.paid_ranks, self.bonus) def cost_string(self, include_grants: bool = True) -> str | None: + if self.hidden: + return "?" if not self.value or self.is_option_template: return self.purchase_cost_string() # Things that cost (or grant) currency should show a cost. @@ -161,6 +167,8 @@ def tags(self) -> set[str]: def name_with_tags( self, exclude_tags: set[str] | None = None, include_cost: bool = False ) -> str: + if self.hidden: + return "???" name = self.display_name() if tags := self.render_tags(exclude_tags=exclude_tags): name = f"{name} {tags}" @@ -184,6 +192,8 @@ def _max_ranks_tag(self) -> str: return f"{self.max_ranks}" def power_card(self) -> defs.PowerCard | None: + if self.hidden: + return None return self.definition.model_copy( update={ "name": self.name_with_tags(include_cost=True), @@ -206,6 +216,8 @@ def render_tags(self, exclude_tags: set[str] | None = None) -> str: return " ".join(out) def sub_cards(self) -> list[defs.PowerCard]: + if self.hidden: + return [] if isinstance(self.definition.subcard, list): return list(self.definition.subcard) elif self.definition.subcard: @@ -233,6 +245,8 @@ def purchase_cost_string( cost: int | None = None, grants: int | None = None, ) -> str | None: + if self.hidden: + return "?" if self.currency and (self.cost_def is not None or cost is not None): if cost is None: if self.is_option_template: @@ -280,6 +294,8 @@ def _link_model(self) -> None: @property def explain(self) -> list[str]: """Returns a list of strings explaining details of the feature.""" + if self.hidden: + return ["It's a secret."] if self.model.plot_suppressed: return ["This feature was suppressed by a plot member."] diff --git a/src/camp/engine/rules/tempest/controllers/religion_controller.py b/src/camp/engine/rules/tempest/controllers/religion_controller.py index 5afbb87..986dca7 100644 --- a/src/camp/engine/rules/tempest/controllers/religion_controller.py +++ b/src/camp/engine/rules/tempest/controllers/religion_controller.py @@ -18,6 +18,8 @@ def can_afford(self, value: int = 1) -> Decision: def level_label(self) -> str: devs = self.devotions() + if all(d.value > 0 for d in devs): + return "MAX" if devs: match devs[-1].level_value: case 2: diff --git a/src/camp/engine/rules/tempest/defs.py b/src/camp/engine/rules/tempest/defs.py index f90518b..8d8b838 100644 --- a/src/camp/engine/rules/tempest/defs.py +++ b/src/camp/engine/rules/tempest/defs.py @@ -579,7 +579,12 @@ class Ruleset(base_models.BaseRuleset): ), Attribute( id="basic-classes", - name="Basic Classes", + name="Base Classes", + hidden=True, + ), + Attribute( + id="advanced-classes", + name="Advanced Classes", hidden=True, ), Attribute( @@ -600,12 +605,23 @@ class Ruleset(base_models.BaseRuleset): hidden=True, scoped=True, ), + Attribute( + id="veteran", + name="Veteran", + hidden=True, + scoped=True, + ), Attribute( id="devotion", name="Devotion Powers", hidden=True, scoped=False, ), + Attribute( + id="flag", + name="Flag", + hidden=True, + ), ] def feature_model_types(self) -> base_models.ModelDefinition: diff --git a/tests/rulesets/tempest_test/perks/secret-perk.yaml b/tests/rulesets/tempest_test/perks/secret-perk.yaml new file mode 100644 index 0000000..3a36d6d --- /dev/null +++ b/tests/rulesets/tempest_test/perks/secret-perk.yaml @@ -0,0 +1,5 @@ +name: Secret Perk +cost: 1 +hidden: true +requires: flag+secret +description: This perk is a secret to everyone. diff --git a/tests/tempest_test/test_character.py b/tests/tempest_test/test_character.py index 6450b47..80d30c9 100644 --- a/tests/tempest_test/test_character.py +++ b/tests/tempest_test/test_character.py @@ -54,3 +54,27 @@ def test_attribute_cache_uses_full_id(character: TempestCharacter): assert character.apply("cleric:2") assert character.get("spell@2") == 0 assert character.get("spell@1") > 0 + + +def test_secret_perk_not_available_without_flag(character: TempestCharacter): + perks = { + f.id for f in character.list_features(type="perk", taken=False, available=True) + } + assert "secret-perk" not in perks + + controller = character.feature_controller("secret-perk") + assert controller.hidden + + +def test_secret_perk_availabile_with_flag(character: TempestCharacter): + character.model.metadata = character.model.metadata.model_copy( + update={"flags": {"secret": 1}} + ) + + perks = { + f.id for f in character.list_features(type="perk", taken=False, available=True) + } + assert "secret-perk" in perks + + controller = character.feature_controller("secret-perk") + assert not controller.hidden