1
0
Fork 0

formalize game systems, start implementing effects

This commit is contained in:
Sean Sube 2024-05-15 23:12:06 -05:00
parent 4d90cbef33
commit fee406e607
Signed by: ssube
GPG Key ID: 3EED7B957D362AF1
20 changed files with 609 additions and 116 deletions

8
.vscode/launch.json vendored
View File

@ -11,9 +11,11 @@
"program": "${file}",
"args": [
"--world",
"worlds/cyber.json",
"--world-state",
"worlds/cyber-state.json"
"worlds/test-1.json",
"--rooms",
"2",
"--server",
"--optional-actions"
],
"console": "integratedTerminal",
"cwd": "${workspaceFolder}",

View File

@ -10,6 +10,7 @@ from adventure.search import (
find_item_in_room,
find_room,
)
from adventure.utils.world import describe_entity
logger = getLogger(__name__)
@ -26,28 +27,28 @@ def action_look(target: str) -> str:
if target.lower() == action_room.name.lower():
broadcast(f"{action_actor.name} saw the {action_room.name} room")
return action_room.description
return describe_entity(action_room)
target_actor = find_actor_in_room(action_room, target)
if target_actor:
broadcast(
f"{action_actor.name} saw the {target_actor.name} actor in the {action_room.name} room"
)
return target_actor.description
return describe_entity(target_actor)
target_item = find_item_in_room(action_room, target)
if target_item:
broadcast(
f"{action_actor.name} saw the {target_item.name} item in the {action_room.name} room"
)
return target_item.description
return describe_entity(target_item)
target_item = find_item_in_actor(action_actor, target)
if target_item:
broadcast(
f"{action_actor.name} saw the {target_item.name} item in their inventory"
)
return target_item.description
return describe_entity(target_item)
return "You do not see that item or character in the room."
@ -104,18 +105,13 @@ def action_ask(character: str, question: str) -> str:
question: The question to ask them.
"""
# capture references to the current actor and room, because they will be overwritten
_, action_room, action_actor = get_current_context()
if not action_actor or not action_room:
raise ValueError(
"The current actor and room must be set before calling action_ask"
)
_world, _room, action_actor = get_current_context()
# sanity checks
if character == action_actor.name:
question_actor, question_agent = get_actor_agent_for_name(character)
if question_actor == action_actor:
return "You cannot ask yourself a question. Stop talking to yourself. Try another action."
question_actor, question_agent = get_actor_agent_for_name(character)
if not question_actor:
return f"The {character} character is not in the room."
@ -147,18 +143,13 @@ def action_tell(character: str, message: str) -> str:
message: The message to tell them.
"""
# capture references to the current actor and room, because they will be overwritten
_, action_room, action_actor = get_current_context()
if not action_actor or not action_room:
raise ValueError(
"The current actor and room must be set before calling action_tell"
)
_world, _room, action_actor = get_current_context()
# sanity checks
if character == action_actor.name:
question_actor, question_agent = get_actor_agent_for_name(character)
if question_actor == action_actor:
return "You cannot tell yourself a message. Stop talking to yourself. Try another action."
question_actor, question_agent = get_actor_agent_for_name(character)
if not question_actor:
return f"The {character} character is not in the room."

View File

@ -1,7 +1,8 @@
from typing import Callable, Dict, Tuple
from typing import Callable, Dict, List, Sequence, Tuple
from packit.agent import Agent
from adventure.game_system import GameSystem
from adventure.models.entity import Actor, Room, World
from adventure.models.event import GameEvent
@ -11,6 +12,7 @@ current_room: Room | None = None
current_actor: Actor | None = None
current_step = 0
dungeon_master: Agent | None = None
game_systems: List[GameSystem] = []
# TODO: where should this one go?
@ -71,6 +73,10 @@ def get_dungeon_master() -> Agent:
return dungeon_master
def get_game_systems() -> List[GameSystem]:
return game_systems
# endregion
@ -109,6 +115,11 @@ def set_dungeon_master(agent):
dungeon_master = agent
def set_game_systems(systems: Sequence[GameSystem]):
global game_systems
game_systems = list(systems)
# endregion

66
adventure/game_system.py Normal file
View File

@ -0,0 +1,66 @@
from enum import Enum
from typing import Callable, Protocol
from packit.agent import Agent
from adventure.models.entity import World, WorldEntity
class FormatPerspective(Enum):
FIRST_PERSON = "first-person"
SECOND_PERSON = "second-person"
THIRD_PERSON = "third-person"
# TODO: remove the attributes parameter from all of these
class SystemFormat(Protocol):
def __call__(
self,
entity: WorldEntity,
perspective: FormatPerspective = FormatPerspective.SECOND_PERSON,
) -> str:
# TODO: should this return a list?
...
class SystemGenerate(Protocol):
def __call__(self, agent: Agent, theme: str, entity: WorldEntity) -> None:
"""
Generate a new world entity based on the given theme and entity.
"""
...
class SystemSimulate(Protocol):
def __call__(self, world: World, step: int) -> None:
"""
Simulate the world for the given step.
"""
...
class GameSystem:
format: SystemFormat | None = None
generate: SystemGenerate | None = None
simulate: SystemSimulate | None = None
# render: TODO
def __init__(
self,
format: SystemFormat | None = None,
generate: SystemGenerate | None = None,
simulate: SystemSimulate | None = None,
):
self.format = format
self.generate = generate
self.simulate = simulate
def __str__(self):
return f"GameSystem(format={format_callable(self.format)}, generate={format_callable(self.generate)}, simulate={format_callable(self.simulate)})"
def format_callable(fn: Callable | None) -> str:
if fn:
return f"{fn.__module__}:{fn.__name__}"
return "None"

View File

@ -4,8 +4,19 @@ from typing import List
from packit.agent import Agent
from packit.loops import loop_retry
from packit.utils import could_be_json
from adventure.models.entity import Actor, Item, Room, World, WorldEntity
from adventure.game_system import GameSystem
from adventure.models.entity import (
Actor,
Effect,
Item,
NumberAttributeEffect,
Room,
StringAttributeEffect,
World,
WorldEntity,
)
from adventure.models.event import EventCallback, GenerateEvent
logger = getLogger(__name__)
@ -19,19 +30,22 @@ OPPOSITE_DIRECTIONS = {
def duplicate_name_parser(existing_names: List[str]):
def name_parser(name: str, **kwargs):
logger.debug(f"validating name: {name}")
def name_parser(value: str, **kwargs):
print(f"validating generated name: {value}")
if name in existing_names:
raise ValueError(f'"{name}" has already been used.')
if value in existing_names:
raise ValueError(f'"{value}" has already been used.')
if '"' in name or ":" in name:
if could_be_json(value):
raise ValueError("The name cannot contain JSON or other commands.")
if '"' in value or ":" in value:
raise ValueError("The name cannot contain quotes or colons.")
if len(name) > 50:
if len(value) > 50:
raise ValueError("The name cannot be longer than 50 characters.")
return name
return value
return name_parser
@ -123,8 +137,23 @@ def generate_item(
)
actions = {}
item = Item(name=name, description=desc, actions=actions)
return Item(name=name, description=desc, actions=actions)
effect_count = randint(1, 2)
callback_wrapper(
callback, message=f"Generating {effect_count} effects for item: {name}"
)
effects = []
for i in range(effect_count):
try:
effect = generate_effect(agent, world_theme, entity=item, callback=callback)
effects.append(effect)
except Exception:
logger.exception("error generating effect")
item.effects = effects
return item
def generate_actor(
@ -171,6 +200,100 @@ def generate_actor(
)
def generate_effect(
agent: Agent, theme: str, entity: Item, callback: EventCallback | None = None
) -> Effect:
entity_type = entity.type
existing_effects = [effect.name for effect in entity.effects]
name = loop_retry(
agent,
"Generate one effect for an {entity_type} named {entity.name} that would make sense in the world of {theme}. "
"Only respond with the effect name in title case, do not include a description or any other text. "
'Do not prefix the name with "the", do not wrap it in quotes. Use a unique name. '
"Do not create any duplicate effects on the same item. The existing effects are: {existing_effects}. "
"Some example effects are: 'fire', 'poison', 'frost', 'haste', 'slow', and 'heal'.",
context={
"entity_type": entity_type,
"existing_effects": existing_effects,
"theme": theme,
},
result_parser=duplicate_name_parser(existing_effects),
)
callback_wrapper(callback, message=f"Generating effect: {name}")
description = agent(
"Generate a detailed description of the {name} effect. What does it look like? What does it do? "
"How does it affect the target? Describe the effect from the perspective of an outside observer.",
name=name,
)
attribute_names = agent(
"Generate a list of attributes that the {name} effect modifies. "
"For example, 'heal' increases the target's 'health' attribute, while 'poison' decreases it. "
"Use a comma-separated list of attribute names, such as 'health, strength, speed'. "
"Only include the attribute names, do not include the question or any JSON.",
name=name,
)
attributes = []
for attribute_name in attribute_names.split(","):
attribute_name = attribute_name.strip()
if attribute_name:
operation = agent(
f"How does the {name} effect modify the {attribute_name} attribute? "
"For example, 'heal' might 'add' to the 'health' attribute, while 'poison' might 'subtract' from it."
"Another example is 'writing' might 'set' the 'text' attribute, while 'break' might 'set' the 'condition' attribute."
"Choose from the following operations: {operations}",
name=name,
attribute_name=attribute_name,
operations=[
"set",
"add",
"subtract",
"multiply",
"divide",
"append",
"prepend",
],
)
value = agent(
f"How much does the {name} effect modify the {attribute_name} attribute? "
"For example, 'heal' might 'add' 10 to the 'health' attribute, while 'poison' might 'subtract' 5 from it."
"Enter a positive or negative number, or a string value.",
name=name,
attribute_name=attribute_name,
)
value = value.strip()
if value.isdigit():
value = int(value)
attribute_effect = NumberAttributeEffect(
name=attribute_name, operation=operation, value=value
)
elif value.isdecimal():
value = float(value)
attribute_effect = NumberAttributeEffect(
name=attribute_name, operation=operation, value=value
)
else:
attribute_effect = StringAttributeEffect(
name=attribute_name, operation=operation, value=value
)
attributes.append(attribute_effect)
return Effect(name=name, description=description, attributes=[])
def generate_system_attributes(
agent: Agent, theme: str, entity: WorldEntity, systems: List[GameSystem] = []
) -> None:
for system in systems:
if system.generate:
system.generate(agent, theme, entity)
def generate_world(
agent: Agent,
name: str,
@ -178,6 +301,7 @@ def generate_world(
room_count: int | None = None,
max_rooms: int = 5,
callback: EventCallback | None = None,
systems: List[GameSystem] = [],
) -> World:
room_count = room_count or randint(3, max_rooms)
@ -194,6 +318,7 @@ def generate_world(
room = generate_room(
agent, theme, existing_rooms=existing_rooms, callback=callback
)
generate_system_attributes(agent, theme, room, systems)
callback_wrapper(callback, entity=room)
rooms.append(room)
existing_rooms.append(room.name)
@ -215,6 +340,7 @@ def generate_world(
existing_items=existing_items,
callback=callback,
)
generate_system_attributes(agent, theme, item, systems)
callback_wrapper(callback, entity=item)
room.items.append(item)
@ -236,6 +362,7 @@ def generate_world(
existing_actors=existing_actors,
callback=callback,
)
generate_system_attributes(agent, theme, actor, systems)
callback_wrapper(callback, entity=actor)
room.actors.append(actor)
@ -259,6 +386,7 @@ def generate_world(
existing_items=existing_items,
callback=callback,
)
generate_system_attributes(agent, theme, item, systems)
callback_wrapper(callback, entity=item)
actor.items.append(item)

View File

@ -7,13 +7,12 @@ from pydantic import Field
from rule_engine import Rule
from yaml import Loader, load
from adventure.game_system import FormatPerspective, GameSystem
from adventure.models.entity import (
Actor,
Attributes,
AttributeValue,
Item,
Room,
World,
WorldEntity,
dataclass,
)
from adventure.plugins import get_plugin_function
@ -44,16 +43,15 @@ class LogicTable:
labels: Dict[str, Dict[AttributeValue, LogicLabel]] = Field(default_factory=dict)
LogicTrigger = Callable[[Room | Actor | Item, Attributes], Attributes]
LogicTrigger = Callable[[WorldEntity], None]
TriggerTable = Dict[str, LogicTrigger]
def update_attributes(
entity: Room | Actor | Item,
attributes: Attributes,
entity: WorldEntity,
rules: LogicTable,
triggers: TriggerTable,
) -> Attributes:
) -> None:
entity_type = entity.__class__.__name__.lower()
skip_groups = set()
@ -64,7 +62,7 @@ def update_attributes(
continue
typed_attributes = {
**attributes,
**entity.attributes,
"type": entity_type,
}
@ -93,52 +91,46 @@ def update_attributes(
skip_groups.add(rule.group)
for key in rule.remove or []:
attributes.pop(key, None)
entity.attributes.pop(key, None)
if rule.set:
attributes.update(rule.set)
entity.attributes.update(rule.set)
logger.info("logic set state: %s", rule.set)
if rule.trigger:
for trigger in rule.trigger:
if trigger in triggers:
attributes = triggers[trigger](entity, attributes)
return attributes
triggers[trigger](entity)
def update_logic(
world: World, step: int, rules: LogicTable, triggers: TriggerTable
) -> None:
for room in world.rooms:
room.attributes = update_attributes(
room, room.attributes, rules=rules, triggers=triggers
)
update_attributes(room, rules=rules, triggers=triggers)
for actor in room.actors:
actor.attributes = update_attributes(
actor, actor.attributes, rules=rules, triggers=triggers
)
update_attributes(actor, rules=rules, triggers=triggers)
for item in actor.items:
item.attributes = update_attributes(
item, item.attributes, rules=rules, triggers=triggers
)
update_attributes(item, rules=rules, triggers=triggers)
for item in room.items:
item.attributes = update_attributes(
item, item.attributes, rules=rules, triggers=triggers
)
update_attributes(item, rules=rules, triggers=triggers)
logger.info("updated world attributes")
def format_logic(attributes: Attributes, rules: LogicTable, self=True) -> str:
def format_logic(
entity: WorldEntity,
rules: LogicTable,
perspective: FormatPerspective = FormatPerspective.SECOND_PERSON,
) -> str:
labels = []
for attribute, value in attributes.items():
for attribute, value in entity.attributes.items():
if attribute in rules.labels and value in rules.labels[attribute]:
label = rules.labels[attribute][value]
if self:
if perspective == FormatPerspective.SECOND_PERSON:
labels.append(label.backstory)
elif label.description:
elif perspective == FormatPerspective.THIRD_PERSON and label.description:
labels.append(label.description)
else:
logger.debug("label has no relevant description: %s", label)
@ -149,7 +141,7 @@ def format_logic(attributes: Attributes, rules: LogicTable, self=True) -> str:
return " ".join(labels)
def init_from_file(filename: str):
def load_logic(filename: str):
logger.info("loading logic from file: %s", filename)
with open(filename) as file:
logic_rules = LogicTable(**load(file, Loader=Loader))
@ -161,9 +153,12 @@ def init_from_file(filename: str):
logic_triggers[trigger] = get_plugin_function(trigger)
logger.info("initialized logic system")
return (
wraps(update_logic)(
system_simulate = wraps(update_logic)(
partial(update_logic, rules=logic_rules, triggers=logic_triggers)
),
wraps(format_logic)(partial(format_logic, rules=logic_rules)),
)
system_format = wraps(format_logic)(partial(format_logic, rules=logic_rules))
return GameSystem(
format=system_format,
simulate=system_simulate,
)

View File

@ -9,6 +9,7 @@ from packit.utils import logger_with_colors
from yaml import Loader, load
from adventure.context import set_current_step, set_dungeon_master
from adventure.game_system import GameSystem
from adventure.generate import generate_world
from adventure.models.config import Config
from adventure.models.entity import World, WorldState
@ -83,6 +84,7 @@ def parse_args():
)
parser.add_argument(
"--max-rooms",
default=6,
type=int,
help="The maximum number of rooms to generate",
)
@ -113,7 +115,7 @@ def parse_args():
)
parser.add_argument(
"--server",
type=str,
action="store_true",
help="The address on which to run the server",
)
parser.add_argument(
@ -220,8 +222,9 @@ def load_or_generate_world(
save_world(world, world_file)
# run the systems once to initialize everything
for system_update, _ in systems:
system_update(world, 0)
for system in systems:
if system.simulate:
system.simulate(world, 0)
create_agents(world, memory=memory, players=players)
return (world, world_state_file)
@ -299,18 +302,16 @@ def main():
extra_actions.extend(module_actions)
# load extra systems from plugins
extra_systems = []
extra_systems: List[GameSystem] = []
for system_name in args.systems or []:
logger.info(f"loading extra systems from {system_name}")
module_systems = load_plugin(system_name)
logger.info(
f"loaded extra systems: {[component.__name__ for system in module_systems for component in system]}"
)
logger.info(f"loaded extra systems: {module_systems}")
extra_systems.extend(module_systems)
# make sure the server system runs after any updates
if args.server:
extra_systems.append((server_system, None))
extra_systems.append(GameSystem(simulate=server_system))
# load or generate the world
world_prompt = get_world_prompt(args)
@ -323,7 +324,7 @@ def main():
logger.info("taking snapshot of world state")
save_world_state(world, step, world_state_file)
extra_systems.append((snapshot_system, None))
extra_systems.append(GameSystem(simulate=snapshot_system))
# hack: send a snapshot to the websocket server
if args.server:

View File

@ -5,16 +5,51 @@ from pydantic import Field
from .base import BaseModel, dataclass, uuid
Actions = Dict[str, Callable]
AttributeValue = bool | int | str
AttributeValue = bool | float | int | str
Attributes = Dict[str, AttributeValue]
@dataclass
class StringAttributeEffect:
name: str
operation: Literal["set", "append", "prepend"]
value: str
@dataclass
class NumberAttributeEffect:
name: str
operation: Literal["set", "add", "subtract", "multiply", "divide"]
# TODO: make this a range
value: int | float
@dataclass
class BooleanAttributeEffect:
name: str
operation: Literal["set", "toggle"]
value: bool
AttributeEffect = StringAttributeEffect | NumberAttributeEffect | BooleanAttributeEffect
@dataclass
class Effect(BaseModel):
name: str
description: str
attributes: list[AttributeEffect] = Field(default_factory=list)
id: str = Field(default_factory=uuid)
type: Literal["effect"] = "effect"
@dataclass
class Item(BaseModel):
name: str
description: str
actions: Actions = Field(default_factory=dict)
attributes: Attributes = Field(default_factory=dict)
effects: List[Effect] = Field(default_factory=list)
items: List["Item"] = Field(default_factory=list)
id: str = Field(default_factory=uuid)
type: Literal["item"] = "item"

View File

@ -13,6 +13,8 @@ from adventure.context import (
)
from adventure.generate import OPPOSITE_DIRECTIONS, generate_item, generate_room
from adventure.search import find_actor_in_room
from adventure.utils.effect import apply_effect
from adventure.utils.world import describe_actor, describe_entity
logger = getLogger(__name__)
@ -128,10 +130,35 @@ def action_use(item: str, target: str) -> str:
if not target_actor:
return f"The {target} character is not in the room."
broadcast(f"{action_actor.name} uses {item} on {target}")
outcome = dungeon_master(
effect_names = [effect.name for effect in action_item.effects]
chosen_name = dungeon_master(
f"{action_actor.name} uses {item} on {target}. "
f"{action_actor.description}. {target_actor.description}. {action_item.description}. "
f"{item} has the following effects: {effect_names}. "
"Which effect should be applied? Specify the name of the effect to apply."
"Do not include the question or any JSON. Only include the name of the effect to apply."
)
chosen_name = chosen_name.strip()
chosen_effect = next(
(
search_effect
for search_effect in action_item.effects
if search_effect.name == chosen_name
),
None,
)
if not chosen_effect:
# TODO: should retry the question if the effect is not found
return f"The {chosen_name} effect is not available to apply."
apply_effect(chosen_effect, target_actor.attributes)
broadcast(
f"{action_actor.name} uses the {chosen_name} effect of {item} on {target}"
)
outcome = dungeon_master(
f"{action_actor.name} uses the {chosen_name} effect of {item} on {target}. "
f"{describe_actor(action_actor)}. {describe_actor(target_actor)}. {describe_entity(action_item)}. "
f"What happens? How does {target} react? Be creative with the results. The outcome can be good, bad, or neutral."
"Decide based on the characters involved and the item being used."
"Specify the outcome of the action. Do not include the question or any JSON. Only include the outcome of the action."

View File

@ -27,6 +27,7 @@ from adventure.models.event import (
ResultEvent,
StatusEvent,
)
from adventure.utils.world import describe_entity
logger = getLogger(__name__)
@ -224,23 +225,26 @@ def generate_images(
def prompt_from_event(event: GameEvent) -> str | None:
if isinstance(event, ActionEvent):
if event.item:
return f"{event.actor.name} uses the {event.item.name}. {event.item.description}. {event.actor.description}. {event.room.description}."
return (
f"{event.actor.name} uses the {event.item.name}. {describe_entity(event.item)}. "
f"{describe_entity(event.actor)}. {describe_entity(event.room)}."
)
action_name = event.action.removeprefix("action_")
return f"{event.actor.name} uses {action_name}. {event.actor.description}. {event.room.description}."
return f"{event.actor.name} uses {action_name}. {describe_entity(event.actor)}. {describe_entity(event.room)}."
if isinstance(event, ReplyEvent):
return event.text
if isinstance(event, ResultEvent):
return f"{event.result}. {event.actor.description}. {event.room.description}."
return f"{event.result}. {describe_entity(event.actor)}. {describe_entity(event.room)}."
if isinstance(event, StatusEvent):
if event.room:
if event.actor:
return f"{event.text}. {event.actor.description}. {event.room.description}."
return f"{event.text}. {describe_entity(event.actor)}. {describe_entity(event.room)}."
return f"{event.text}. {event.room.description}."
return f"{event.text}. {describe_entity(event.room)}."
return event.text
@ -248,7 +252,7 @@ def prompt_from_event(event: GameEvent) -> str | None:
def prompt_from_entity(entity: WorldEntity) -> str:
return entity.description
return describe_entity(entity)
def sanitize_name(name: str) -> str:

View File

@ -2,7 +2,7 @@ from .hunger_actions import action_cook, action_eat
from .hygiene_actions import action_wash
from .sleeping_actions import action_sleep
from adventure.logic import init_from_file
from adventure.logic import load_logic
LOGIC_FILES = [
"./adventure/sim_systems/environment_logic.yaml",
@ -26,4 +26,4 @@ def init_actions():
def init_logic():
return [init_from_file(filename) for filename in LOGIC_FILES]
return [load_logic(filename) for filename in LOGIC_FILES]

View File

@ -5,6 +5,7 @@ from adventure.context import (
get_dungeon_master,
)
from adventure.search import find_actor_in_room, find_item_in_room
from adventure.utils.world import describe_entity
def action_attack(target: str) -> str:
@ -33,8 +34,8 @@ def action_attack(target: str) -> str:
)
outcome = dungeon_master(
f"{action_actor.name} attacks {target} in the {action_room.name}. {action_room.description}."
f"{action_actor.description}. {target_actor.description}."
f"{action_actor.name} attacks {target} in the {action_room.name}. {describe_entity(action_room)}."
f"{describe_entity(action_actor)}. {describe_entity(target_actor)}."
f"{target} reacts by {reaction}. What is the outcome of the attack? Describe the result in detail."
)
@ -46,8 +47,8 @@ def action_attack(target: str) -> str:
return description
elif target_item:
outcome = dungeon_master(
f"{action_actor.name} attacks {target} in the {action_room.name}. {action_room.description}."
f"{action_actor.description}. {target_item.description}."
f"{action_actor.name} attacks {target} in the {action_room.name}. {describe_entity(action_room)}."
f"{describe_entity(action_actor)}. {describe_entity(target_item)}."
f"What is the outcome of the attack? Describe the result in detail."
)
@ -78,8 +79,8 @@ def action_cast(target: str, spell: str) -> str:
dungeon_master = get_dungeon_master()
outcome = dungeon_master(
f"{action_actor.name} casts {spell} on {target} in the {action_room.name}. {action_room.description}."
f"{action_actor.description}. {target_actor.description if target_actor else target_item.description}."
f"{action_actor.name} casts {spell} on {target} in the {action_room.name}. {describe_entity(action_room)}."
f"{describe_entity(action_actor)}. {describe_entity(target_actor) if target_actor else describe_entity(target_item)}."
f"What is the outcome of the spell? Describe the result in detail."
)

View File

@ -1,6 +1,7 @@
rules:
# cooking logic
- match:
- group: cooking
match:
type: item
edible: true
cooked: false
@ -8,7 +9,8 @@ rules:
set:
spoiled: true
- match:
- group: cooking
match:
type: item
edible: true
cooked: true
@ -17,7 +19,8 @@ rules:
spoiled: true
# hunger logic
- match:
- group: hunger
match:
type: actor
hunger: full
chance: 0.1
@ -25,13 +28,15 @@ rules:
hunger: hungry
# hunger initialization
- rule: |
- group: hunger
rule: |
"hunger" not in attributes
set:
hunger: full
# thirst logic
- match:
- group: thirst
match:
type: actor
thirst: hydrated
chance: 0.1
@ -39,7 +44,8 @@ rules:
thirst: thirsty
# thirst initialization
- rule: |
- group: thirst
rule: |
"thirst" not in attributes
set:
thirst: hydrated

View File

@ -1,4 +1,5 @@
from adventure.context import get_current_context, get_dungeon_master
from adventure.utils.world import describe_entity
def action_wash(unused: bool) -> str:
@ -11,7 +12,7 @@ def action_wash(unused: bool) -> str:
dungeon_master = get_dungeon_master()
outcome = dungeon_master(
f"{action_actor.name} washes themselves in the {action_room.name}. {action_room.description}. {action_actor.description}"
f"{action_actor.name} washes themselves in the {action_room.name}. {describe_entity(action_room)}. {describe_entity(action_actor)}"
f"{action_actor.name} was {hygiene} to start with. How clean are they after washing? Respond with 'clean' or 'dirty'."
"If the room has a shower or running water, they should be cleaner. If the room is dirty, they should end up dirtier."
)

View File

@ -1,4 +1,5 @@
from adventure.context import get_current_context, get_dungeon_master
from adventure.utils.world import describe_entity
def action_sleep(unused: bool) -> str:
@ -10,7 +11,7 @@ def action_sleep(unused: bool) -> str:
dungeon_master = get_dungeon_master()
outcome = dungeon_master(
f"{action_actor.name} sleeps in the {action_room.name}. {action_room.description}. {action_actor.description}"
f"{action_actor.name} sleeps in the {action_room.name}. {describe_entity(action_room)}. {describe_entity(action_actor)}"
"How rested are they? Respond with 'rested' or 'tired'."
)

View File

@ -1,5 +1,5 @@
from logging import getLogger
from typing import Callable, Sequence, Tuple
from typing import Callable, Sequence
from packit.loops import loop_retry
from packit.results import multi_function_or_str_result
@ -24,8 +24,10 @@ from adventure.context import (
set_current_room,
set_current_step,
set_current_world,
set_game_systems,
)
from adventure.models.entity import Attributes, World
from adventure.game_system import GameSystem
from adventure.models.entity import World
from adventure.models.event import (
ActionEvent,
EventCallback,
@ -34,6 +36,7 @@ from adventure.models.event import (
ResultEvent,
StatusEvent,
)
from adventure.utils.world import describe_entity, format_attributes
logger = getLogger(__name__)
@ -62,13 +65,12 @@ def simulate_world(
world: World,
steps: int = 10,
actions: Sequence[Callable[..., str]] = [],
systems: Sequence[
Tuple[Callable[[World, int], None], Callable[[Attributes], str] | None]
] = [],
callbacks: Sequence[EventCallback] = [],
systems: Sequence[GameSystem] = [],
):
logger.info("Simulating the world")
set_current_world(world)
set_game_systems(systems)
# set up a broadcast callback
def broadcast_callback(message: str | GameEvent):
@ -116,11 +118,7 @@ def simulate_world(
room_items = [item.name for item in room.items]
room_directions = list(room.portals.keys())
actor_attributes = " ".join(
system_format(actor.attributes)
for _, system_format in systems
if system_format
)
actor_attributes = format_attributes(actor)
actor_items = [item.name for item in actor.items]
def result_parser(value, agent, **kwargs):
@ -161,7 +159,7 @@ def simulate_world(
"attributes": actor_attributes,
"directions": room_directions,
"room_name": room.name,
"room_description": room.description,
"room_description": describe_entity(room),
"visible_actors": room_actors,
"visible_items": room_items,
},
@ -176,7 +174,8 @@ def simulate_world(
for callback in callbacks:
callback(result_event)
for system_update, _ in systems:
system_update(world, current_step)
for system in systems:
if system.simulate:
system.simulate(world, current_step)
set_current_step(current_step + 1)

View File

@ -0,0 +1,120 @@
from adventure.models.entity import Attributes, AttributeValue
def add_attribute(attributes: Attributes, name: str, value: int | float) -> Attributes:
"""
Add an attribute to a set of attributes.
"""
if name in attributes:
previous_value = attributes[name]
if isinstance(previous_value, str):
raise ValueError(f"Cannot add a number to a string attribute: {name}")
attributes[name] = value + previous_value
else:
attributes[name] = value
return attributes
def subtract_attribute(
attributes: Attributes, name: str, value: int | float
) -> Attributes:
"""
Subtract an attribute from a set of attributes.
"""
if name in attributes:
previous_value = attributes[name]
if isinstance(previous_value, str):
raise ValueError(
f"Cannot subtract a number from a string attribute: {name}"
)
attributes[name] = value - previous_value
else:
attributes[name] = -value
return attributes
def multiply_attribute(
attributes: Attributes, name: str, value: int | float
) -> Attributes:
"""
Multiply an attribute in a set of attributes.
"""
if name in attributes:
previous_value = attributes[name]
if isinstance(previous_value, str):
raise ValueError(f"Cannot multiply a string attribute: {name}")
attributes[name] = previous_value * value
else:
attributes[name] = 0
return attributes
def divide_attribute(
attributes: Attributes, name: str, value: int | float
) -> Attributes:
"""
Divide an attribute in a set of attributes.
"""
if name in attributes:
previous_value = attributes[name]
if isinstance(previous_value, str):
raise ValueError(f"Cannot divide a string attribute: {name}")
attributes[name] = previous_value / value
else:
attributes[name] = 0
return attributes
def set_attribute(
attributes: Attributes, name: str, value: AttributeValue
) -> Attributes:
"""
Set an attribute in a set of attributes.
"""
attributes[name] = value
return attributes
def append_attribute(attributes: Attributes, name: str, value: str) -> Attributes:
"""
Append a string to an attribute in a set of attributes.
"""
if name in attributes:
previous_value = attributes[name]
if isinstance(previous_value, str):
attributes[name] = previous_value + value
else:
raise ValueError(
f"Cannot append a string to a non-string attribute: {name}"
)
else:
attributes[name] = value
return attributes
def prepend_attribute(attributes: Attributes, name: str, value: str) -> Attributes:
"""
Prepend a string to an attribute in a set of attributes.
"""
if name in attributes:
previous_value = attributes[name]
if isinstance(previous_value, str):
attributes[name] = value + previous_value
else:
raise ValueError(
f"Cannot prepend a string to a non-string attribute: {name}"
)
else:
attributes[name] = value
return attributes

47
adventure/utils/effect.py Normal file
View File

@ -0,0 +1,47 @@
from typing import List
from adventure.models.entity import Attributes, Effect
from adventure.utils.attribute import (
add_attribute,
append_attribute,
divide_attribute,
multiply_attribute,
prepend_attribute,
set_attribute,
subtract_attribute,
)
def apply_effect(effect: Effect, attributes: Attributes) -> Attributes:
"""
Apply an effect to a set of attributes.
"""
for attribute in effect.attributes:
if attribute.operation == "set":
set_attribute(attributes, attribute.name, attribute.value)
elif attribute.operation == "add":
add_attribute(attributes, attribute.name, attribute.value)
elif attribute.operation == "subtract":
subtract_attribute(attributes, attribute.name, attribute.value)
elif attribute.operation == "multiply":
multiply_attribute(attributes, attribute.name, attribute.value)
elif attribute.operation == "divide":
divide_attribute(attributes, attribute.name, attribute.value)
elif attribute.operation == "append":
append_attribute(attributes, attribute.name, attribute.value)
elif attribute.operation == "prepend":
prepend_attribute(attributes, attribute.name, attribute.value)
else:
raise ValueError(f"Invalid operation: {attribute.operation}")
return attributes
def apply_effects(effects: List[Effect], attributes: Attributes) -> Attributes:
"""
Apply a list of effects to a set of attributes.
"""
for effect in effects:
attributes = apply_effect(effect, attributes)
return attributes

52
adventure/utils/world.py Normal file
View File

@ -0,0 +1,52 @@
from logging import getLogger
from adventure.context import get_game_systems
from adventure.game_system import FormatPerspective
from adventure.models.entity import Actor, WorldEntity
logger = getLogger(__name__)
def describe_actor(
actor: Actor, perspective: FormatPerspective = FormatPerspective.SECOND_PERSON
) -> str:
attribute_descriptions = format_attributes(actor, perspective=perspective)
logger.info("describing actor: %s, %s", actor, attribute_descriptions)
if perspective == FormatPerspective.SECOND_PERSON:
actor_description = actor.backstory
elif perspective == FormatPerspective.THIRD_PERSON:
actor_description = actor.description
else:
raise ValueError(f"Perspective {perspective} is not implemented")
return f"{actor_description} {attribute_descriptions}"
def describe_static(entity: WorldEntity) -> str:
# TODO: include attributes
return entity.description
def describe_entity(
entity: WorldEntity,
perspective: FormatPerspective = FormatPerspective.SECOND_PERSON,
) -> str:
if isinstance(entity, Actor):
return describe_actor(entity, perspective)
return describe_static(entity)
def format_attributes(
entity: WorldEntity,
perspective: FormatPerspective = FormatPerspective.SECOND_PERSON,
) -> str:
systems = get_game_systems()
attribute_descriptions = [
system.format(entity, perspective=perspective)
for system in systems
if system.format
]
return f"{'. '.join(attribute_descriptions)}"

View File

@ -187,6 +187,12 @@ export function RenderEventItem(props: EventItemProps) {
const { images } = event;
return <ListItem alignItems="flex-start" ref={props.focusRef}>
<ListItemAvatar>
<Avatar alt="Render">
<Camera />
</Avatar>
</ListItemAvatar>
<ListItemText primary="">Render</ListItemText>
<ImageList cols={3} rowHeight={256}>
{Object.entries(images).map(([name, image]) => <ImageListItem key={name}>
<a href='#' onClick={() => openImage(image as string)}>