Skip to content

Commit

Permalink
Support hidden features.
Browse files Browse the repository at this point in the history
  • Loading branch information
kw committed Mar 26, 2024
1 parent abdbe76 commit 68e4ecd
Show file tree
Hide file tree
Showing 9 changed files with 121 additions and 9 deletions.
40 changes: 32 additions & 8 deletions src/camp/engine/rules/base_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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}]"
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/camp/engine/rules/base_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/camp/engine/rules/tempest/controllers/attribute_controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {}
Expand Down
16 changes: 16 additions & 0 deletions src/camp/engine/rules/tempest/controllers/feature_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}"
Expand All @@ -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),
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 17 additions & 1 deletion src/camp/engine/rules/tempest/defs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions tests/rulesets/tempest_test/perks/secret-perk.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: Secret Perk
cost: 1
hidden: true
requires: flag+secret
description: This perk is a secret to everyone.
24 changes: 24 additions & 0 deletions tests/tempest_test/test_character.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 68e4ecd

Please sign in to comment.