1
0
Fork 0

add thought stage with planning, add quest system
Run Docker Build / build (push) Failing after 9s Details
Run Python Build / build (push) Failing after 16s Details

This commit is contained in:
Sean Sube 2024-05-25 15:18:40 -05:00
parent 2aaf531454
commit a3cb7c3e4b
Signed by: ssube
GPG Key ID: 3EED7B957D362AF1
21 changed files with 896 additions and 121 deletions

View File

@ -84,7 +84,9 @@ def action_move(direction: str) -> str:
if not destination_room:
return f"The {portal.destination} room does not exist."
broadcast(f"{action_actor.name} moves {direction} to {destination_room.name}")
broadcast(
f"{action_actor.name} moves through {direction} to {destination_room.name}"
)
action_room.actors.remove(action_actor)
destination_room.actors.append(action_actor)

View File

@ -0,0 +1,135 @@
from adventure.context import action_context, get_current_step
from adventure.models.planning import CalendarEvent
def take_note(fact: str):
"""
Remember a fact by recording it in your notes. Facts are critical information about yourself and others that you
have learned during your adventures. You can review your notes at any time to help you make decisions.
Args:
fact: The fact to remember.
"""
with action_context() as (_, action_actor):
if fact in action_actor.planner.notes:
return "You already know that."
action_actor.planner.notes.append(fact)
return "You make a note of that."
def read_notes(unused: bool, count: int = 10):
"""
Read your notes to review the facts that you have learned during your adventures.
Args:
count: The number of recent notes to read. 10 is usually a good number.
"""
facts = get_recent_notes(count=count)
return "\n".join(facts)
def erase_notes(prefix: str) -> str:
"""
Erase notes that start with a specific prefix.
Args:
prefix: The prefix to match notes against.
"""
with action_context() as (_, action_actor):
matches = [
note for note in action_actor.planner.notes if note.startswith(prefix)
]
if not matches:
return "No notes found with that prefix."
action_actor.planner.notes[:] = [
note for note in action_actor.planner.notes if note not in matches
]
return f"Erased {len(matches)} notes."
def replace_note(old: str, new: str) -> str:
"""
Replace a note with a new note.
Args:
old: The old note to replace.
new: The new note to replace it with.
"""
with action_context() as (_, action_actor):
if old not in action_actor.planner.notes:
return "Note not found."
action_actor.planner.notes[:] = [
new if note == old else note for note in action_actor.planner.notes
]
return "Note replaced."
def schedule_event(name: str, turns: int):
"""
Schedule an event to happen at a specific turn. Events are important occurrences that can affect the world in
significant ways. You will be notified about upcoming events so you can plan accordingly.
Args:
name: The name of the event.
turns: The number of turns until the event happens.
"""
with action_context() as (_, action_actor):
# TODO: check for existing events with the same name
event = CalendarEvent(name, turns)
action_actor.planner.calendar.events.append(event)
return f"{name} is scheduled to happen in {turns} turns."
def read_calendar(unused: bool, count: int = 10):
"""
Read your calendar to see upcoming events that you have scheduled.
"""
current_turn = get_current_step()
with action_context() as (_, action_actor):
events = action_actor.planner.calendar.events[:count]
return "\n".join(
[
f"{event.name} will happen in {event.turn - current_turn} turns"
for event in events
]
)
def get_upcoming_events(turns: int = 3):
"""
Get a list of upcoming events within a certain number of turns.
Args:
turns: The number of turns to look ahead for events.
"""
current_turn = get_current_step()
with action_context() as (_, action_actor):
calendar = action_actor.planner.calendar
# TODO: sort events by turn
return [
event for event in calendar.events if event.turn - current_turn <= turns
]
def get_recent_notes(count: int = 3):
"""
Get the most recent facts from your notes.
Args:
history: The number of recent facts to retrieve.
"""
with action_context() as (_, action_actor):
return action_actor.planner.notes[-count:]

View File

@ -0,0 +1,58 @@
from adventure.context import action_context, get_system_data
from adventure.systems.quest import (
QUEST_SYSTEM,
complete_quest,
get_active_quest,
get_quests_for_actor,
set_active_quest,
)
from adventure.utils.search import find_actor_in_room
def accept_quest(actor: str, quest: str) -> str:
"""
Accept and start a quest being given by another character.
"""
with action_context() as (action_room, action_actor):
quests = get_system_data(QUEST_SYSTEM)
if not quests:
return "No quests available."
target_actor = find_actor_in_room(action_room, actor)
if not target_actor:
return f"{actor} is not in the room."
available_quests = get_quests_for_actor(quests, target_actor)
for available_quest in available_quests:
if available_quest.name == quest:
set_active_quest(quests, action_actor, available_quest)
return f"You have accepted the quest: {quest}"
return f"{actor} does not have the quest: {quest}"
def submit_quest(actor: str) -> str:
"""
Submit your active quest to the quest giver. If you have completed the quest, you will be rewarded.
"""
with action_context() as (action_room, action_actor):
quests = get_system_data(QUEST_SYSTEM)
if not quests:
return "No quests available."
active_quest = get_active_quest(quests, action_actor)
if not active_quest:
return "You do not have an active quest."
target_actor = find_actor_in_room(action_room, actor)
if not target_actor:
return f"{actor} is not in the room."
if active_quest.giver.actor == target_actor.name:
complete_quest(quests, action_actor, active_quest)
return f"You have completed the quest: {active_quest.name}"
return f"{actor} is not the quest giver for your active quest."

View File

@ -2,6 +2,7 @@ from contextlib import contextmanager
from logging import getLogger
from types import UnionType
from typing import (
Any,
Callable,
Dict,
List,
@ -33,6 +34,7 @@ dungeon_master: Agent | None = None
# game context
event_emitter = EventEmitter()
game_systems: List[GameSystem] = []
system_data: Dict[str, Any] = {}
# TODO: where should this one go?
@ -155,6 +157,10 @@ def get_game_systems() -> List[GameSystem]:
return game_systems
def get_system_data(system: str) -> Any | None:
return system_data.get(system)
# endregion
@ -193,6 +199,10 @@ def set_game_systems(systems: Sequence[GameSystem]):
game_systems = list(systems)
def set_system_data(system: str, data: Any):
system_data[system] = data
# endregion

View File

@ -1,10 +1,9 @@
from enum import Enum
from typing import Protocol
from typing import Any, Callable, Protocol
from packit.agent import Agent
from adventure.models.entity import World, WorldEntity
from adventure.utils import format_callable
class FormatPerspective(Enum):
@ -31,32 +30,59 @@ class SystemGenerate(Protocol):
...
class SystemInitialize(Protocol):
def __call__(self, world: World) -> Any:
"""
Initialize the system for the given world.
"""
...
class SystemSimulate(Protocol):
def __call__(self, world: World, step: int) -> None:
def __call__(self, world: World, step: int, data: Any | None = None) -> None:
"""
Simulate the world for the given step.
"""
...
class SystemData:
load: Callable[[str], Any]
save: Callable[[str, Any], None]
def __init__(self, load: Callable[[str], Any], save: Callable[[str, Any], None]):
self.load = load
self.save = save
class GameSystem:
name: str
data: SystemData | None = None
format: SystemFormat | None = None
generate: SystemGenerate | None = None
initialize: SystemInitialize | None = None
simulate: SystemSimulate | None = None
# render: TODO
def __init__(
self,
name: str,
*,
data: SystemData | None = None,
format: SystemFormat | None = None,
generate: SystemGenerate | None = None,
initialize: SystemInitialize | None = None,
simulate: SystemSimulate | None = None,
):
self.name = name
self.data = data
self.format = format
self.generate = generate
self.initialize = initialize
self.simulate = simulate
def __str__(self):
return f"GameSystem(format={format_callable(self.format)}, generate={format_callable(self.generate)}, simulate={format_callable(self.simulate)})"
return f"GameSystem({self.name})"
def __repr__(self):
return str(self)

View File

@ -7,7 +7,7 @@ from packit.loops import loop_retry
from packit.results import enum_result, int_result
from packit.utils import could_be_json
from adventure.context import broadcast, set_current_world
from adventure.context import broadcast, set_current_world, set_system_data
from adventure.game_system import GameSystem
from adventure.models.config import DEFAULT_CONFIG, WorldConfig
from adventure.models.effect import (
@ -74,6 +74,7 @@ def generate_system_attributes(
) -> None:
for system in systems:
if system.generate:
# TODO: pass the whole world
system.generate(agent, world.theme, entity)
@ -423,6 +424,7 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern:
"name": name,
},
result_parser=int_result,
toolbox=None,
)
def parse_application(value: str, **kwargs) -> str:
@ -447,6 +449,7 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern:
"name": name,
},
result_parser=parse_application,
toolbox=None,
)
return EffectPattern(
@ -522,6 +525,12 @@ def generate_world(
world = World(name=name, rooms=[], theme=theme, order=[])
set_current_world(world)
# initialize the systems
for system in systems:
if system.initialize:
data = system.initialize(world)
set_system_data(system.name, data)
# generate the rooms
for _ in range(room_count):
try:

View File

@ -6,14 +6,9 @@ from typing import List
from dotenv import load_dotenv
from packit.agent import Agent, agent_easy_connect
from packit.utils import logger_with_colors
from yaml import Loader, load
from adventure.context import subscribe
def load_yaml(file):
return load(file, Loader=Loader)
from adventure.context import get_system_data, set_system_data
from adventure.utils.file import load_yaml
# configure logging
LOG_PATH = "logging.json"
@ -34,7 +29,7 @@ logger = logger_with_colors(__name__) # , level="DEBUG")
load_dotenv(environ.get("ADVENTURE_ENV", ".env"), override=True)
if True:
from adventure.context import set_current_step, set_dungeon_master
from adventure.context import set_current_step, set_dungeon_master, subscribe
from adventure.game_system import GameSystem
from adventure.generate import generate_world
from adventure.models.config import DEFAULT_CONFIG, Config
@ -176,7 +171,34 @@ def get_world_prompt(args) -> WorldPrompt:
)
def load_or_generate_world(args, players, systems, world_prompt: WorldPrompt):
def load_or_initialize_system_data(args, systems: List[GameSystem], world: World):
for system in systems:
if system.data:
system_data_file = f"{args.world}.{system.name}.json"
data = None
if path.exists(system_data_file):
logger.info(f"loading system data from {system_data_file}")
data = system.data.load(system_data_file)
else:
logger.info(f"no system data found at {system_data_file}")
if system.initialize:
data = system.initialize(world)
set_system_data(system.name, data)
def save_system_data(args, systems: List[GameSystem]):
for system in systems:
if system.data:
system_data_file = f"{args.world}.{system.name}.json"
logger.info(f"saving system data to {system_data_file}")
system.data.save(system_data_file, get_system_data(system.name))
def load_or_generate_world(
args, players, systems: List[GameSystem], world_prompt: WorldPrompt
):
world_file = args.world + ".json"
world_state_file = args.state or (args.world + ".state.json")
@ -187,6 +209,7 @@ def load_or_generate_world(args, players, systems, world_prompt: WorldPrompt):
state = WorldState(**load_yaml(f))
set_current_step(state.step)
load_or_initialize_system_data(args, systems, state.world)
memory = state.memory
world = state.world
@ -194,6 +217,8 @@ def load_or_generate_world(args, players, systems, world_prompt: WorldPrompt):
logger.info(f"loading world from {world_file}")
with open(world_file, "r") as f:
world = World(**load_yaml(f))
load_or_initialize_system_data(args, systems, world)
else:
logger.info(f"generating a new world using theme: {world_prompt.theme}")
llm = agent_easy_connect()
@ -213,11 +238,7 @@ def load_or_generate_world(args, players, systems, world_prompt: WorldPrompt):
room_count=args.rooms,
)
save_world(world, world_file)
# run the systems once to initialize everything
for system in systems:
if system.simulate:
system.simulate(world, 0)
save_system_data(args, systems)
create_agents(world, memory=memory, players=players)
return (world, world_state_file)
@ -296,7 +317,7 @@ def main():
if args.server:
from adventure.server.websocket import server_system
extra_systems.append(GameSystem(simulate=server_system))
extra_systems.append(GameSystem(name="server", simulate=server_system))
# load or generate the world
world_prompt = get_world_prompt(args)
@ -305,11 +326,11 @@ def main():
)
# make sure the snapshot system runs last
def snapshot_system(world: World, step: int) -> None:
def snapshot_system(world: World, step: int, data: None = None) -> None:
logger.info("taking snapshot of world state")
save_world_state(world, step, world_state_file)
extra_systems.append(GameSystem(simulate=snapshot_system))
extra_systems.append(GameSystem(name="snapshot", simulate=snapshot_system))
# hack: send a snapshot to the websocket server
if args.server:

View File

@ -4,6 +4,7 @@ from pydantic import Field
from .base import Attributes, BaseModel, dataclass, uuid
from .effect import EffectPattern, EffectResult
from .planning import Planner
Actions = Dict[str, Callable]
@ -26,6 +27,7 @@ class Actor(BaseModel):
name: str
backstory: str
description: str
planner: Planner = Field(default_factory=Planner)
actions: Actions = Field(default_factory=dict)
active_effects: List[EffectResult] = Field(default_factory=list)
attributes: Attributes = Field(default_factory=dict)
@ -79,3 +81,11 @@ class WorldState(BaseModel):
WorldEntity = Room | Actor | Item | Portal
@dataclass
class EntityReference:
actor: str | None = None
item: str | None = None
portal: str | None = None
room: str | None = None

View File

@ -0,0 +1,22 @@
from typing import List
from pydantic import Field
from adventure.models.base import dataclass
@dataclass
class CalendarEvent:
name: str
turn: int
@dataclass
class Calendar:
events: List[CalendarEvent] = Field(default_factory=list)
@dataclass
class Planner:
calendar: Calendar = Field(default_factory=Calendar)
notes: List[str] = Field(default_factory=list)

View File

@ -296,7 +296,7 @@ async def server_main():
await asyncio.Future() # run forever
def server_system(world: World, step: int):
def server_system(world: World, step: int, data: Any | None = None):
global last_snapshot
id = uuid4().hex # TODO: should a server be allowed to generate event IDs?
json_state = {

View File

@ -1,9 +1,12 @@
from functools import partial
from itertools import count
from logging import getLogger
from math import inf
from typing import Callable, Sequence
from packit.loops import loop_retry
from packit.agent import Agent
from packit.conditions import condition_or, condition_threshold, make_flag_condition
from packit.loops import loop_reduce, loop_retry
from packit.results import multi_function_or_str_result
from packit.toolbox import Toolbox
from packit.utils import could_be_json
@ -16,6 +19,16 @@ from adventure.actions.base import (
action_take,
action_tell,
)
from adventure.actions.planning import (
erase_notes,
get_recent_notes,
get_upcoming_events,
read_calendar,
read_notes,
replace_note,
schedule_event,
take_note,
)
from adventure.context import (
broadcast,
get_actor_agent_for_name,
@ -29,10 +42,11 @@ from adventure.context import (
set_game_systems,
)
from adventure.game_system import GameSystem
from adventure.models.entity import World
from adventure.models.entity import Actor, Room, World
from adventure.models.event import ActionEvent, ReplyEvent, ResultEvent
from adventure.utils.effect import is_active_effect
from adventure.utils.effect import expire_effects
from adventure.utils.search import find_room_with_actor
from adventure.utils.string import normalize_name
from adventure.utils.world import describe_entity, format_attributes
logger = getLogger(__name__)
@ -58,13 +72,136 @@ def world_result_parser(value, agent, **kwargs):
return multi_function_or_str_result(value, agent=agent, **kwargs)
def prompt_actor_action(room, actor, agent, action_names, action_toolbox) -> str:
# collect data for the prompt
room_actors = [actor.name for actor in room.actors]
room_items = [item.name for item in room.items]
room_directions = [portal.name for portal in room.portals]
actor_attributes = format_attributes(actor)
# actor_effects = [effect.name for effect in actor.active_effects]
actor_items = [item.name for item in actor.items]
# set up a result parser for the agent
def result_parser(value, agent, **kwargs):
if not room or not actor:
raise ValueError("Room and actor must be set before parsing results")
if could_be_json(value):
event = ActionEvent.from_json(value, room, actor)
else:
event = ReplyEvent.from_text(value, room, actor)
broadcast(event)
return world_result_parser(value, agent, **kwargs)
# prompt and act
logger.info("starting turn for actor: %s", actor.name)
result = loop_retry(
agent,
(
"You are currently in {room_name}. {room_description}. {attributes}. "
"The room contains the following characters: {visible_actors}. "
"The room contains the following items: {visible_items}. "
"Your inventory contains the following items: {actor_items}."
"You can take the following actions: {actions}. "
"You can move in the following directions: {directions}. "
"What will you do next? Reply with a JSON function call, calling one of the actions."
"You can only perform one action per turn. What is your next action?"
),
context={
"actions": action_names,
"actor_items": actor_items,
"attributes": actor_attributes,
"directions": room_directions,
"room_name": room.name,
"room_description": describe_entity(room),
"visible_actors": room_actors,
"visible_items": room_items,
},
result_parser=result_parser,
toolbox=action_toolbox,
)
logger.debug(f"{actor.name} step result: {result}")
if agent.memory:
# TODO: make sure this is not duplicating memories and wasting space
agent.memory.append(result)
return result
def prompt_actor_think(
room: Room, actor: Actor, agent: Agent, planner_toolbox: Toolbox
) -> str:
recent_notes = get_recent_notes()
upcoming_events = get_upcoming_events()
if len(recent_notes) > 0:
notes = "\n".join(recent_notes)
notes_prompt = f"Your recent notes are: {notes}\n"
else:
notes_prompt = "You have no recent notes.\n"
if len(upcoming_events) > 0:
current_step = get_current_step()
events = [
f"{event.name} in {event.turn - current_step} turns"
for event in upcoming_events
]
events = "\n".join(events)
events_prompt = f"Upcoming events are: {events}\n"
else:
events_prompt = "You have no upcoming events.\n"
event_count = len(actor.planner.calendar.events)
note_count = len(actor.planner.notes)
logger.info("starting planning for actor: %s", actor.name)
set_end, condition_end = make_flag_condition()
def result_parser(value, **kwargs):
if normalize_name(value) == "end":
set_end()
return multi_function_or_str_result(value, **kwargs)
stop_condition = condition_or(condition_end, partial(condition_threshold, max=3))
result = loop_reduce(
agent,
"You are about to take your turn. Plan your next action carefully. "
"You can check your notes for important facts or check your calendar for upcoming events. You have {note_count} notes. "
"Try to keep your notes accurate. Replace or erase old notes if they are no longer accurate or useful. "
"If you have upcoming events with other characters, schedule them on your calendar. You have {event_count} calendar events. "
"Think about your goals and any quests that you are working on, and plan your next action accordingly. "
"You can perform up to 3 planning actions in a single turn. When you are done planning, reply with 'END'."
"{notes_prompt} {events_prompt}",
context={
"event_count": event_count,
"events_prompt": events_prompt,
"note_count": note_count,
"notes_prompt": notes_prompt,
},
result_parser=result_parser,
stop_condition=stop_condition,
toolbox=planner_toolbox,
)
if agent.memory:
agent.memory.append(result)
return result
def simulate_world(
world: World,
steps: float | int = inf,
actions: Sequence[Callable[..., str]] = [],
systems: Sequence[GameSystem] = [],
):
logger.info("Simulating the world")
logger.info("simulating the world")
set_current_world(world)
set_game_systems(systems)
@ -82,6 +219,18 @@ def simulate_world(
)
action_names = action_tools.list_tools()
# build a toolbox for the planners
planner_toolbox = Toolbox(
[
take_note,
read_notes,
replace_note,
erase_notes,
schedule_event,
read_calendar,
]
)
# simulate each actor
for i in count():
current_step = get_current_step()
@ -90,79 +239,31 @@ def simulate_world(
for actor_name in world.order:
actor, agent = get_actor_agent_for_name(actor_name)
if not agent or not actor:
logger.error(f"Agent or actor not found for name {actor_name}")
logger.error(f"agent or actor not found for name {actor_name}")
continue
room = find_room_with_actor(world, actor)
if not room:
logger.error(f"Actor {actor_name} is not in a room")
logger.error(f"actor {actor_name} is not in a room")
continue
# prep context
set_current_room(room)
set_current_actor(actor)
# decrement effects on the actor and remove any that have expired
for effect in actor.active_effects:
if effect.duration is not None:
effect.duration -= 1
expire_effects(actor)
# TODO: expire calendar events
actor.active_effects[:] = [
effect for effect in actor.active_effects if is_active_effect(effect)
]
# collect data for the prompt
room_actors = [actor.name for actor in room.actors]
room_items = [item.name for item in room.items]
room_directions = [portal.name for portal in room.portals]
actor_attributes = format_attributes(actor)
actor_items = [item.name for item in actor.items]
# set up a result parser for the agent
def result_parser(value, agent, **kwargs):
if not room or not actor:
raise ValueError(
"Room and actor must be set before parsing results"
)
if could_be_json(value):
event = ActionEvent.from_json(value, room, actor)
else:
event = ReplyEvent.from_text(value, room, actor)
broadcast(event)
return world_result_parser(value, agent, **kwargs)
# prompt and act
logger.info("starting turn for actor: %s", actor_name)
result = loop_retry(
agent,
(
"You are currently in {room_name}. {room_description}. {attributes}. "
"The room contains the following characters: {visible_actors}. "
"The room contains the following items: {visible_items}. "
"Your inventory contains the following items: {actor_items}."
"You can take the following actions: {actions}. "
"You can move in the following directions: {directions}. "
"What will you do next? Reply with a JSON function call, calling one of the actions."
"You can only perform one action per turn. What is your next action?"
),
context={
"actions": action_names,
"actor_items": actor_items,
"attributes": actor_attributes,
"directions": room_directions,
"room_name": room.name,
"room_description": describe_entity(room),
"visible_actors": room_actors,
"visible_items": room_items,
},
result_parser=result_parser,
toolbox=action_tools,
)
logger.debug(f"{actor.name} step result: {result}")
if agent.memory:
agent.memory.append(result)
# give the actor a chance to think and check their planner
if agent.memory and len(agent.memory) > 0:
try:
thoughts = prompt_actor_think(room, actor, agent, planner_toolbox)
logger.debug(f"{actor.name} thinks: {thoughts}")
except Exception:
logger.exception(f"error during planning for actor {actor.name}")
result = prompt_actor_action(room, actor, agent, action_names, action_tools)
result_event = ResultEvent(result=result, room=room, actor=actor)
broadcast(result_event)
@ -171,6 +272,6 @@ def simulate_world(
system.simulate(world, current_step)
set_current_step(current_step + 1)
if i > steps:
if i >= steps:
logger.info("reached step limit at world step %s", current_step + 1)
break

View File

@ -1,7 +1,8 @@
from functools import partial, wraps
from logging import getLogger
from os import path
from random import random
from typing import Dict, List, Optional, Protocol
from typing import Any, Dict, List, Optional, Protocol
from pydantic import Field
from rule_engine import Rule
@ -128,7 +129,12 @@ def update_attributes(
def update_logic(
world: World, step: int, rules: LogicTable, triggers: TriggerTable
world: World,
step: int,
data: Any | None = None,
*,
rules: LogicTable,
triggers: TriggerTable
) -> None:
for room in world.rooms:
update_attributes(room, rules=rules, triggers=triggers)
@ -165,7 +171,9 @@ def format_logic(
def load_logic(filename: str):
logger.info("loading logic from file: %s", filename)
system_name = "logic-" + path.splitext(path.basename(filename))[0]
logger.info("loading logic from file %s as system %s", filename, system_name)
with open(filename) as file:
logic_rules = LogicTable(**load(file, Loader=Loader))
logic_triggers = {}
@ -179,12 +187,17 @@ def load_logic(filename: str):
logic_triggers[trigger] = get_plugin_function(function_name)
logger.info("initialized logic system")
system_format = wraps(format_logic)(partial(format_logic, rules=logic_rules))
system_initialize = wraps(load_logic)(
partial(update_logic, step=0, rules=logic_rules, triggers=logic_triggers)
)
system_simulate = wraps(update_logic)(
partial(update_logic, rules=logic_rules, triggers=logic_triggers)
)
system_format = wraps(format_logic)(partial(format_logic, rules=logic_rules))
return GameSystem(
name=system_name,
format=system_format,
initialize=system_initialize,
simulate=system_simulate,
)

233
adventure/systems/quest.py Normal file
View File

@ -0,0 +1,233 @@
from logging import getLogger
from typing import Dict, List, Literal, Optional
from packit.agent import Agent
from pydantic import Field
from adventure.context import get_system_data
from adventure.game_system import GameSystem, SystemData
from adventure.models.base import Attributes, dataclass, uuid
from adventure.models.entity import (
Actor,
EntityReference,
Item,
Room,
World,
WorldEntity,
)
from adventure.systems.logic import match_logic
from adventure.utils.search import (
find_entity_reference,
find_item_in_container,
find_item_in_room,
)
from adventure.utils.systems import load_system_data, save_system_data
logger = getLogger(__name__)
QUEST_SYSTEM = "quest"
@dataclass
class QuestGoalContains:
"""
Quest goal for any kind of fetch quest, including delivery and escort quests.
Valid combinations are:
- container: Room and items: List[Actor | Item]
- container: Actor and items: List[Item]
"""
container: EntityReference
contents: List[EntityReference] = Field(default_factory=list)
type: Literal["contains"] = "contains"
@dataclass
class QuestGoalAttributes:
"""
Quest goal for any kind of attribute quest, including spell casting and item usage.
"""
target: EntityReference
match: Optional[Attributes] = None
rule: Optional[str] = None
type: Literal["attributes"] = "attributes"
QuestGoal = QuestGoalAttributes | QuestGoalContains
@dataclass
class QuestReward:
pass
@dataclass
class Quest:
name: str
description: str
giver: EntityReference
goal: QuestGoal
reward: QuestReward
type: Literal["quest"] = "quest"
id: str = Field(default_factory=uuid)
@dataclass
class QuestData:
active: Dict[str, Quest]
available: Dict[str, List[Quest]]
completed: Dict[str, List[Quest]]
# region quest completion
def is_quest_complete(world: World, quest: Quest) -> bool:
"""
Check if the given quest is complete.
"""
if quest.goal.type == "contains":
container = find_entity_reference(world, quest.goal.container)
if not container:
raise ValueError(f"quest container not found: {quest.goal.container}")
for content in quest.goal.contents:
if isinstance(container, Room):
if content.item:
if not find_item_in_room(container, content.item):
return False
elif isinstance(container, (Actor, Item)):
if content.item:
if not find_item_in_container(container, content.item):
return False
else:
logger.warning(f"unsupported container type: {container}")
return False
return True
elif quest.goal.type == "attributes":
target = find_entity_reference(world, quest.goal.target)
if not target:
raise ValueError(f"quest target not found: {quest.goal.target}")
if match_logic(target, quest.goal):
return True
return False
# endregion
# region state management
def get_quests_for_actor(quests: QuestData, actor: Actor) -> List[Quest]:
"""
Get all quests for the given actor.
"""
return quests.available.get(actor.name, [])
def set_active_quest(quests: QuestData, actor: Actor, quest: Quest) -> None:
"""
Set the active quest for the given actor.
"""
quests.active[actor.name] = quest
def get_active_quest(quests: QuestData, actor: Actor) -> Quest | None:
"""
Get the active quest for the given actor.
"""
return quests.active.get(actor.name)
def complete_quest(quests: QuestData, actor: Actor, quest: Quest) -> None:
"""
Complete the given quest for the given actor.
"""
if quest in quests.available.get(actor.name, []):
quests.available[actor.name].remove(quest)
if quest == quests.active.get(actor.name, None):
del quests.active[actor.name]
if actor.name not in quests.completed:
quests.completed[actor.name] = []
quests.completed[actor.name].append(quest)
# endregion
def initialize_quests(world: World) -> QuestData:
"""
Initialize quests for the world.
"""
logger.info("initializing quest data for world %s", world.name)
return QuestData(active={}, available={}, completed={})
def generate_quests(agent: Agent, theme: str, entity: WorldEntity) -> None:
"""
Generate new quests for the world.
"""
quests: QuestData | None = get_system_data(QUEST_SYSTEM)
if not quests:
raise ValueError("Quest data is required for quest generation")
if isinstance(entity, Actor):
available_quests = get_quests_for_actor(quests, entity)
if len(available_quests) == 0:
logger.info(f"generating new quest for {entity.name}")
# TODO: generate one new quest
def simulate_quests(world: World, step: int, data: QuestData | None = None) -> None:
"""
1. Check for any completed quests.
2. Update any active quests.
3. Generate any new quests.
"""
if not data:
# TODO: initialize quest data for worlds that don't have it
raise ValueError("Quest data is required for simulation")
for room in world.rooms:
for actor in room.actors:
active_quest = get_active_quest(data, actor)
if active_quest:
logger.info(f"simulating quest for {actor.name}: {active_quest.name}")
if is_quest_complete(world, active_quest):
logger.info(f"quest complete for {actor.name}: {active_quest.name}")
complete_quest(data, actor, active_quest)
def load_quest_data(file: str) -> QuestData:
logger.info(f"loading quest data from {file}")
return load_system_data(QuestData, file)
def save_quest_data(file: str, data: QuestData) -> None:
logger.info(f"saving quest data to {file}")
return save_system_data(QuestData, file, data)
def init() -> List[GameSystem]:
return [
GameSystem(
QUEST_SYSTEM,
data=SystemData(
load=load_quest_data,
save=save_quest_data,
),
generate=generate_quests,
initialize=initialize_quests,
simulate=simulate_quests,
)
]

View File

@ -3,7 +3,7 @@ from .language_actions import action_read
from .magic_actions import action_cast
from .movement_actions import action_climb
from adventure.logic import load_logic
from adventure.systems.logic import load_logic
LOGIC_FILES = [
"./adventure/systems/rpg/weather_logic.yaml",

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 load_logic
from adventure.systems.logic import load_logic
LOGIC_FILES = [
"./adventure/systems/sim/environment_logic.yaml",

View File

@ -298,7 +298,7 @@ def apply_permanent_effects(
def apply_effects(target: Actor, effects: List[EffectPattern]) -> None:
"""
Apply a set of effects to a set of attributes.
Apply a set of effects to an actor and their attributes.
"""
permanent_effects = [
@ -312,3 +312,17 @@ def apply_effects(target: Actor, effects: List[EffectPattern]) -> None:
]
temporary_effects = resolve_effects(temporary_effects)
target.active_effects.extend(temporary_effects)
def expire_effects(target: Actor) -> None:
"""
Decrement the duration of effects on an actor and remove any that have expired.
"""
for effect in target.active_effects:
if effect.duration is not None:
effect.duration -= 1
target.active_effects[:] = [
effect for effect in target.active_effects if is_active_effect(effect)
]

9
adventure/utils/file.py Normal file
View File

@ -0,0 +1,9 @@
from yaml import Loader, dump, load
def load_yaml(file):
return load(file, Loader=Loader)
def save_yaml(file, data):
return dump(data, file)

View File

@ -1,6 +1,14 @@
from typing import Any, Generator
from adventure.models.entity import Actor, Item, Portal, Room, World
from adventure.models.entity import (
Actor,
EntityReference,
Item,
Portal,
Room,
World,
WorldEntity,
)
from .string import normalize_name
@ -59,20 +67,11 @@ def find_item(
def find_item_in_actor(
actor: Actor, item_name: str, include_item_inventory=False
) -> Item | None:
for item in actor.items:
if normalize_name(item.name) == normalize_name(item_name):
return item
if include_item_inventory:
item = find_item_in_container(item, item_name, include_item_inventory)
if item:
return item
return None
return find_item_in_container(actor, item_name, include_item_inventory)
def find_item_in_container(
container: Item, item_name: str, include_item_inventory=False
container: Actor | Item, item_name: str, include_item_inventory=False
) -> Item | None:
for item in container.items:
if normalize_name(item.name) == normalize_name(item_name):
@ -130,6 +129,28 @@ def find_containing_room(world: World, entity: Room | Actor | Item) -> Room | No
return None
def find_entity_reference(
world: World, reference: EntityReference
) -> WorldEntity | None:
"""
Resolve an entity reference to an entity in the world.
"""
if reference.room:
return find_room(world, reference.room)
if reference.actor:
return find_actor(world, reference.actor)
if reference.item:
return find_item(world, reference.item)
if reference.portal:
return find_portal(world, reference.portal)
return None
def list_rooms(world: World) -> Generator[Room, Any, None]:
for room in world.rooms:
yield room

View File

@ -0,0 +1,14 @@
from pydantic import RootModel
from adventure.utils.file import load_yaml, save_yaml
def load_system_data(cls, file):
with load_yaml(file) as data:
return cls(**data)
def save_system_data(cls, file, model):
data = RootModel[cls](model).model_dump()
with open(file, "w") as f:
save_yaml(f, data)

View File

@ -1,13 +1,31 @@
import { Maybe, doesExist } from '@apextoaster/js-utils';
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List,
ListItem,
ListItemText,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import { instance as graphviz } from '@viz-js/viz';
import React, { Fragment, useEffect } from 'react';
import { useStore } from 'zustand';
import { Actor, Item, Room, World } from './models';
import { Actor, Attributes, Item, Portal, Room, World } from './models';
import { StoreState, store } from './store';
export interface EntityDetailsProps {
entity: Maybe<Item | Actor | Room>;
entity: Maybe<Item | Actor | Portal | Room>;
onClose: () => void;
onRender: (type: string, entity: string) => void;
}
@ -20,15 +38,58 @@ export function EntityDetails(props: EntityDetailsProps) {
return <Fragment />;
}
const { description, name, type } = entity;
let attributes: Attributes = {};
let planner;
if (type === 'actor') {
const actor = entity as Actor;
attributes = actor.attributes;
planner = actor.planner;
}
if (type === 'item') {
const item = entity as Item;
attributes = item.attributes;
}
return <Fragment>
<DialogTitle>{entity.name}</DialogTitle>
<DialogTitle>{name}</DialogTitle>
<DialogContent dividers>
<Typography>
{entity.description}
</Typography>
<Stack direction='column' spacing={2}>
<Typography>
{description}
</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Attribute</TableCell>
<TableCell>Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(attributes).map(([key, value]) => (
<TableRow key={key}>
<TableCell>{key}</TableCell>
<TableCell>{value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{doesExist(planner) && <List>
{planner.notes.map((note: string) => (
<ListItem>
<ListItemText primary={note} />
</ListItem>
))}
</List>}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => onRender(entity.type, entity.name)}>Render</Button>
<Button onClick={() => onRender(type, name)}>Render</Button>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Fragment>;
@ -94,7 +155,7 @@ export function DetailDialog(props: DetailDialogProps) {
>{details}</Dialog>;
}
export function isWorld(entity: Maybe<Item | Actor | Room | World>): entity is World {
export function isWorld(entity: Maybe<Item | Actor | Portal | Room | World>): entity is World {
return doesExist(entity) && doesExist(Object.getOwnPropertyDescriptor(entity, 'theme'));
}

View File

@ -1,7 +1,20 @@
export type Attributes = Record<string, boolean | number | string>;
export interface CalendarEvent {
name: string;
turn: number;
}
export interface Planner {
calendar: Array<CalendarEvent>;
notes: Array<string>;
}
export interface Item {
type: 'item';
name: string;
description: string;
attributes: Attributes;
}
export interface Actor {
@ -10,6 +23,8 @@ export interface Actor {
backstory: string;
description: string;
items: Array<Item>;
attributes: Attributes;
planner: Planner;
}
export interface Portal {
@ -26,6 +41,7 @@ export interface Room {
actors: Array<Actor>;
items: Array<Item>;
portals: Array<Portal>;
attributes: Attributes;
}
export interface World {