~nromdotcom/gemif

ref: 85dc63b9dbb0221579a7fd26c4a1feae4bd39338 gemif/pkg/gamemanager/gamemanager.go -rw-r--r-- 5.1 KiB
85dc63b9Norm MacLennan chore: upgrade linters and fix linting issues 9 months ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
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) {
	//nolint:exhaustivestruct
	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
}