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) {
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)
}
// 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(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
}