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 ( // 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) { startingScene, _ := gm.GetRoomByID(storyID, gm.Book.Stories[storyID].Metadata.StartingScene) return GameState{ StoryID: storyID, CurrentRoom: startingScene.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) } // 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 switch gm.EngineConfig.StateTokenFormat { case JSON: if unmarshallErr := protojson.Unmarshal(decodedString, &st); unmarshallErr != nil { return GameState{}, fmt.Errorf("failed to load state: %w", unmarshallErr) } case Protobuf: if unmarshallErr := proto.Unmarshal(decodedString, &st); unmarshallErr != nil { return GameState{}, fmt.Errorf("failed to load state: %w", unmarshallErr) } default: return GameState{}, fmt.Errorf("%w: %d", ErrInvalidSerializationFormat, gm.EngineConfig.StateTokenFormat) } 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, } switch gm.EngineConfig.StateTokenFormat { case Protobuf: stateBytes, err := proto.Marshal(&st) if err != nil { return "", fmt.Errorf("failed to save state: %w", err) } return base64.StdEncoding.EncodeToString(stateBytes), nil case JSON: stateBytes, err := protojson.Marshal(&st) if err != nil { return "", fmt.Errorf("failed to save state: %w", err) } return base64.StdEncoding.EncodeToString(stateBytes), nil default: return "", fmt.Errorf("%w: %d", 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)) usableExits := currentRoom.filterExits(gameState) for i, e := range usableExits { newState := gameState.UseExit(e) 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(story StoryMetadata) (SpeculativeState, error) { startState, startErr := gm.StartGame(story.ID) if startErr != nil { return SpeculativeState{}, fmt.Errorf("could not generate starting state: %w", startErr) } startToken, serializeError := gm.SerializeState(startState) if serializeError != nil { return SpeculativeState{}, fmt.Errorf("could not serialize state: %w", serializeError) } return SpeculativeState{ Description: fmt.Sprintf("%s by %s - %s", story.Name, story.Author, story.Description), StateToken: startToken, }, nil }