package gamemanager import ( "encoding/base64" "errors" "fmt" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) // GameManager contains configuration info about stories and managed GameState objects. type GameManager struct { Book StoryBook EngineConfig EngineConfig } // SpeculativeState is used to provide "what if" GameStates for next available actions. type SpeculativeState struct { Description string StateToken string } var ( // ErrInvalidSerializationFormat occurs when engine config specifies a unsupported format. ErrInvalidSerializationFormat = errors.New("invalid serialization format") // ErrRoomNotFound occurs when the room doesn't exist in the given story. ErrRoomNotFound = errors.New("room not found") // ErrExitNotFound occurs when the exit doesn't exit in the given room. ErrExitNotFound = errors.New("exit not found") ) // NewGameManager creates a new GameManager, using Stories in the Config. func NewGameManager(book StoryBook, config EngineConfig) (*GameManager, error) { return &GameManager{ Book: book, EngineConfig: config, }, nil } // StartGame creates a new GameState representing the start of a given storyID. func (gm *GameManager) StartGame(storyID string) (GameState, error) { return GameState{ StoryID: storyID, CurrentRoom: gm.Book.Stories[storyID].Rooms[0].ID, Conditions: []string{}, }, nil } // GetStories lists all StoryMetadata for stories tracked by GameManager. func (gm *GameManager) GetStories() []StoryMetadata { metadata := make([]StoryMetadata, len(gm.Book.Stories)) i := 0 for _, e := range gm.Book.Stories { metadata[i] = e.Metadata i++ } return metadata } // GetRoomByID finds Room information based on room and story IDs. func (gm *GameManager) GetRoomByID(storyID string, roomID string) (Room, error) { for _, r := range gm.Book.Stories[storyID].Rooms { if r.ID == roomID { return r, nil } } return Room{}, fmt.Errorf("%w: %s", ErrRoomNotFound, roomID) } // UseExit mutates gameState based on the selected exitID. func (gm *GameManager) UseExit(gameState GameState, exitID string) (GameState, error) { currentRoom, err := gm.GetRoomByID(gameState.StoryID, gameState.CurrentRoom) if err != nil { return GameState{}, fmt.Errorf("couldn't find room %s in story %s: %w", gameState.CurrentRoom, gameState.StoryID, err) } for _, e := range currentRoom.Exits { if e.ID == exitID { if e.IfCondition != "" && !gameState.ConditionMet(e.IfCondition) { fmt.Printf("Cant use exit! %s\n", e.ID) continue } gameState.CurrentRoom = e.Destination if e.SetCondition != "" { gameState.MeetCondition(e.SetCondition) } return gameState, nil } } return GameState{}, fmt.Errorf("%w: %s", ErrExitNotFound, exitID) } // DeserializeState converts a stateToken into a GameState. func (gm *GameManager) DeserializeState(stateToken string) (GameState, error) { decodedString, decodeErr := base64.StdEncoding.DecodeString(stateToken) if decodeErr != nil { return GameState{}, fmt.Errorf("couldn't decode state token: %w", decodeErr) } var st StateToken if gm.EngineConfig.StateTokenFormat == "proto" { if unmarshallErr := proto.Unmarshal(decodedString, &st); unmarshallErr != nil { return GameState{}, fmt.Errorf("failed to load state: %w", unmarshallErr) } } else if gm.EngineConfig.StateTokenFormat == "json" { if unmarshallErr := protojson.Unmarshal(decodedString, &st); unmarshallErr != nil { return GameState{}, fmt.Errorf("failed to load state: %w", unmarshallErr) } } return GameState{ StoryID: st.StoryID, CurrentRoom: st.CurrentRoom, Conditions: st.Conditions, }, nil } // SerializeState generates a stateToken based on the given GameState. func (gm *GameManager) SerializeState(gameState GameState) (string, error) { st := StateToken{ StoryID: gameState.StoryID, CurrentRoom: gameState.CurrentRoom, Conditions: gameState.Conditions, } if gm.EngineConfig.StateTokenFormat == "proto" { stateBytes, err := proto.Marshal(&st) if err != nil { return "", fmt.Errorf("failed to save state: %w", err) } return base64.StdEncoding.EncodeToString(stateBytes), nil } else if gm.EngineConfig.StateTokenFormat == "json" { stateBytes, err := protojson.Marshal(&st) if err != nil { return "", fmt.Errorf("failed to save state: %w", err) } return base64.StdEncoding.EncodeToString(stateBytes), nil } return "", fmt.Errorf("%w: %s", ErrInvalidSerializationFormat, gm.EngineConfig.StateTokenFormat) } // ConstructSpeculativeStates makes a SpeculativeState from the active GameState and actions available in currentRoom. func (gm *GameManager) ConstructSpeculativeStates(gameState GameState, currentRoom Room) ([]SpeculativeState, error) { speculativeStates := make([]SpeculativeState, len(currentRoom.Exits)) for i, e := range currentRoom.Exits { if e.IfCondition != "" && !isInSlice(gameState.Conditions, e.IfCondition) { continue } newState, exitErr := gm.UseExit(gameState, e.ID) if exitErr != nil { return speculativeStates, fmt.Errorf("trouble using exit %s: %w", e.ID, exitErr) } token, serializeErr := gm.SerializeState(newState) if serializeErr != nil { return speculativeStates, fmt.Errorf("couldn't serialize constructed state: %w", serializeErr) } speculativeStates[i] = SpeculativeState{Description: e.Description, StateToken: token} } return speculativeStates, nil } // ConstructStartingState generates a starting stateToken for each loaded story. func (gm *GameManager) ConstructStartingState(stories []StoryMetadata) ([]SpeculativeState, error) { speculativeStates := make([]SpeculativeState, len(stories)) for i, s := range stories { startState, startErr := gm.StartGame(s.ID) if startErr != nil { return speculativeStates, fmt.Errorf("could not generate starting state: %w", startErr) } startToken, serializeError := gm.SerializeState(startState) if serializeError != nil { return speculativeStates, fmt.Errorf("could not serialize state: %w", serializeError) } speculativeStates[i] = SpeculativeState{ Description: fmt.Sprintf("%s by %s - %s", s.Name, s.Author, s.Description), StateToken: startToken, } } return speculativeStates, nil }