formalize game systems, start implementing effects
This commit is contained in:
parent
4d90cbef33
commit
fee406e607
|
@ -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}",
|
||||
|
|
|
@ -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."
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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"
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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."
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."
|
||||
)
|
||||
|
|
|
@ -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'."
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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)}"
|
|
@ -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)}>
|
||||
|
|
Loading…
Reference in New Issue