end_of_eden/game/session.go
2024-09-02 12:13:16 +02:00

2225 lines
59 KiB
Go

package game
import (
"bytes"
"context"
"encoding/gob"
"errors"
"fmt"
"io"
"log"
"math/rand/v2"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/BigJk/end_of_eden/internal/fs"
"github.com/BigJk/end_of_eden/internal/lua/ludoc"
"github.com/BigJk/end_of_eden/system/gen"
"github.com/BigJk/end_of_eden/system/gen/faces"
"github.com/BigJk/end_of_eden/system/localization"
"github.com/BigJk/end_of_eden/ui"
"github.com/samber/lo"
lua "github.com/yuin/gopher-lua"
"golang.org/x/exp/slices"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
"oss.terrastruct.com/d2/d2lib"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
"oss.terrastruct.com/d2/lib/textmeasure"
)
func init() {
gob.Register(FightState{})
gob.Register(MerchantState{})
}
// GameState represents the current state of the game.
type GameState string
const (
GameStateFight = GameState("FIGHT")
GameStateMerchant = GameState("MERCHANT")
GameStateEvent = GameState("EVENT")
GameStateRandom = GameState("RANDOM")
GameStateGameOver = GameState("GAME_OVER")
)
const (
// DefaultUpgradeCost is the default cost for upgrading a card.
DefaultUpgradeCost = 65
// DefaultRemoveCost is the default cost for removing a card.
DefaultRemoveCost = 50
// DefaultPointsPerRound is the default amount of points the player gets per round.
DefaultPointsPerRound = 3
// DrawSize is the amount of cards the player draws per round.
DrawSize = 3
)
type Hook string
const (
HookNextFightEnd = Hook("NextFightEnd")
)
// FightState represents the current state of the fight in regard to the
// deck of the player.
type FightState struct {
Round int
Description string
CurrentPoints int
Deck []string
Hand []string
Used []string
Exhausted []string
}
// MerchantState represents the current state of the merchant.
type MerchantState struct {
Face string
Text string
Cards []string
Artifacts []string
}
// LuaError represents an error that occurred in lua.
type LuaError struct {
File string
Line int
Callback string
Type string
Err error
}
// Session represents the state inside a game session.
type Session struct {
log *log.Logger
luaState *lua.LState
luaDocs *ludoc.Docs
seed uint64
randSrc *rand.PCG
rand *rand.Rand
resources *ResourcesManager
state GameState
actors map[string]Actor
instances map[string]any
stagesCleared int
currentEvent string
currentFight FightState
pointsPerRound int
merchant MerchantState
eventHistory []string
randomHistory []string
ctxData map[string]any
hooks map[Hook][]func()
loadedMods []string
stateCheckpoints []StateCheckpoint
closer []func() error
onLuaError func(file string, line int, callback string, typeId string, err error)
luaErrors chan LuaError
Logs []LogEntry
}
// NewSession creates a new game session.
func NewSession(options ...func(s *Session)) *Session {
seed := uint64(time.Now().UnixMilli())
randSrc := rand.NewPCG(seed, 1337)
session := &Session{
log: log.New(io.Discard, "", 0),
seed: seed,
randSrc: randSrc,
rand: rand.New(randSrc),
state: GameStateEvent,
actors: map[string]Actor{
PlayerActorID: NewActor(PlayerActorID),
},
pointsPerRound: DefaultPointsPerRound,
instances: map[string]any{},
ctxData: map[string]any{},
hooks: map[Hook][]func(){
HookNextFightEnd: {},
},
stagesCleared: 0,
onLuaError: nil,
luaErrors: make(chan LuaError, 25),
eventHistory: []string{},
randomHistory: []string{},
}
session.SetOnLuaError(nil)
session.luaState, session.luaDocs = SessionAdapter(session)
session.resources = NewResourcesManager(session.luaState, session.luaDocs, session.log)
session.resources.MarkBaseGame()
for i := range options {
if options[i] == nil {
continue
}
options[i](session)
}
session.loadMods(session.loadedMods)
session.log.Println("Session started!")
session.log.Println("Seed:", session.seed)
session.Log(LogTypeSuccess, fmt.Sprintf("Seed: %d", seed))
session.UpdatePlayer(func(actor *Actor) bool {
actor.HP = 100
actor.MaxHP = 100
actor.Gold = 0
return true
})
session.SetEvent("START")
return session
}
// WithDebugEnabled enables the lua debugging. With lua debugging a server will be started
// on the given bind port. This exposes the /ws route to connect over websocket to. In essence,
// it exposes REPL access to the internal lua state which is helpful to debug problems. You can use
// the debug_r function to send data back to the websocket.
//
// Tip: Use https://github.com/websockets/wscat to connect and talk with it.
func WithDebugEnabled(port int) func(s *Session) {
return func(s *Session) {
s.closer = append(s.closer, ExposeDebug(port, s, s.luaState, s.log))
}
}
// WithLogging sets the internal logger.
func WithLogging(logger *log.Logger) func(s *Session) {
return func(s *Session) {
s.log = logger
}
}
// WithMods sets the mods that should be loaded.
func WithMods(mods []string) func(s *Session) {
return func(s *Session) {
s.loadedMods = mods
}
}
// WithLuaString sets the lua code that should be executed.
func WithLuaString(lua string) func(s *Session) {
return func(s *Session) {
if err := s.luaState.DoString(lua); err != nil {
s.logLuaError("WithLuaString", "", err)
}
}
}
// WithSeed sets the seed for the random number generator.
func WithSeed(seed uint64) func(s *Session) {
return func(s *Session) {
s.seed = seed
s.randSrc.Seed(seed, 1337)
}
}
// WithSeedString sets the seed for the random number generator based on a string.
func WithSeedString(seed string) func(s *Session) {
return func(s *Session) {
generatedSeed := uint64(0)
for i, c := range seed {
generatedSeed += uint64(c) + uint64(i)
}
s.seed = generatedSeed
s.randSrc.Seed(generatedSeed, 1337)
}
}
// WithOnLuaError sets the function that will be called when a lua error happens.
func WithOnLuaError(fn func(file string, line int, callback string, typeId string, err error)) func(s *Session) {
return func(s *Session) {
s.onLuaError = fn
}
}
// SetOnLuaError sets the function that will be called when a lua error happens.
func (s *Session) SetOnLuaError(fn func(file string, line int, callback string, typeId string, err error)) {
if fn == nil {
s.onLuaError = func(file string, line int, callback string, typeId string, err error) {}
} else {
s.onLuaError = fn
}
}
// LuaDocs returns the documentation of the lua state.
func (s *Session) LuaDocs() *ludoc.Docs {
return s.luaDocs
}
// Close closes the internal lua state and everything else.
func (s *Session) Close() {
for i := range s.closer {
if err := s.closer[i](); err != nil {
s.log.Println("Close error:", err)
}
}
s.luaState.Close()
}
// LuaErrors returns a channel that will receive all lua errors that happen during the session.
// Only a single channel is used for all errors, so be wary when using this in multiple goroutines.
func (s *Session) LuaErrors() chan LuaError {
return s.luaErrors
}
// ToSavedState creates a saved state of the session that can be serialized with Gob.
func (s *Session) ToSavedState() SavedState {
return SavedState{
State: s.state,
Seed: s.seed,
Rand: s.randSrc,
Actors: s.actors,
Instances: s.instances,
StagesCleared: s.stagesCleared,
CurrentEvent: s.currentEvent,
CurrentFight: s.currentFight,
PointsPerRound: s.pointsPerRound,
Merchant: s.merchant,
EventHistory: s.eventHistory,
StateCheckpoints: lo.Map(s.stateCheckpoints, func(item StateCheckpoint, index int) StateCheckpoint {
return StateCheckpoint{
Session: nil,
Events: item.Events,
}
}),
CtxData: s.ctxData,
LoadedMods: s.loadedMods,
}
}
// LoadSavedState applies a saved state to the session. This will overwrite all game related data, but
// not the lua state, logging etc. This also means that for a save file to work the same lua scripts
// should be loaded or the state could be corrupted.
func (s *Session) LoadSavedState(save SavedState) {
s.state = save.State
s.seed = save.Seed
s.randSrc = save.Rand
s.actors = lo.MapValues(save.Actors, func(item Actor, key string) Actor {
return item.Sanitize()
})
s.instances = save.Instances
s.stagesCleared = save.StagesCleared
s.currentEvent = save.CurrentEvent
s.currentFight = save.CurrentFight
s.pointsPerRound = save.PointsPerRound
s.merchant = save.Merchant
s.eventHistory = save.EventHistory
s.stateCheckpoints = lo.Map(save.StateCheckpoints, func(item StateCheckpoint, index int) StateCheckpoint {
item.Session = s
return item
})
s.ctxData = save.CtxData
s.loadedMods = save.LoadedMods
// Don't load mods from settings but from the saved list!
s.loadMods(s.loadedMods)
}
func (s *Session) GobEncode() ([]byte, error) {
buf := &bytes.Buffer{}
enc := gob.NewEncoder(buf)
err := enc.Encode(s.ToSavedState())
return buf.Bytes(), err
}
func (s *Session) GobDecode(data []byte) error {
buf := bytes.NewBuffer(data)
dec := gob.NewDecoder(buf)
var saved SavedState
if err := dec.Decode(&saved); err != nil {
return err
}
s.LoadSavedState(saved)
return nil
}
// GetResources returns the resources manager.
func (s *Session) GetResources() *ResourcesManager {
return s.resources
}
// GetLoadedMods returns the list of loaded mods.
func (s *Session) GetLoadedMods() []string {
return s.loadedMods
}
//
// Internal
//
func (s *Session) logLuaError(callback string, typeId string, err error) {
_, file, no, ok := runtime.Caller(1)
if ok {
s.log.Printf("%s:%d Error from Lua:%s type=%s %s\n", file, no, callback, typeId, err.Error())
s.onLuaError(file, no, callback, typeId, err)
s.luaErrors <- LuaError{
File: file,
Line: no,
Callback: callback,
Type: typeId,
Err: err,
}
} else {
s.log.Printf("Error from Lua:%s type=%s %s\n", callback, typeId, err.Error())
s.onLuaError("", 0, callback, typeId, err)
s.luaErrors <- LuaError{
File: "",
Line: 0,
Callback: callback,
Type: typeId,
Err: err,
}
}
}
func (s *Session) loadMods(mods []string) {
for i := range mods {
// Load single lua files
if strings.HasSuffix(mods[i], ".lua") && filepath.IsAbs(mods[i]) {
luaBytes, err := fs.ReadFile(mods[i])
if err != nil {
panic(err)
}
if err := s.luaState.DoString(string(luaBytes)); err != nil {
s.logLuaError("ModLoader", "", err)
}
continue
}
mod, err := ModDescription(filepath.Join("./mods", mods[i]))
if err != nil {
log.Println("Error loading mod:", err)
} else {
log.Println("Loading mod:", mod.Name)
}
_ = fs.Walk(filepath.Join("./mods", mods[i]), func(path string, isDir bool) error {
if strings.Contains(path, "__") {
return nil
}
// If we find a locals folder we add it to the localization
if isDir && filepath.Base(path) == "locals" {
_ = localization.Global.AddFolder(path)
}
if !isDir && strings.HasSuffix(path, ".lua") {
luaBytes, err := fs.ReadFile(path)
if err != nil {
// TODO: error handling
panic(err)
}
if strings.HasPrefix(string(luaBytes), "---@meta") {
return nil
}
if err := s.luaState.DoString(string(luaBytes)); err != nil {
s.logLuaError("ModLoader", "", err)
}
}
return nil
})
}
}
//
// Checkpoints
//
// MarkState creates a checkpoint of the session state that can be used to diff and see what happened
// between two points in time.
func (s *Session) MarkState() StateCheckpointMarker {
return StateCheckpointMarker{checkpoints: s.stateCheckpoints}
}
// PushState pushes a new state to the session. New states are relevant information like damage done,
// money received, actor death etc.
func (s *Session) PushState(events map[StateEvent]any) {
savedState := *s
// Only have the current session have the state checkpoints
savedState.stateCheckpoints = make([]StateCheckpoint, 0)
savedState.actors = lo.MapValues(CopyMap(savedState.actors), func(actor Actor, key string) Actor {
return actor.Clone()
})
savedState.instances = CopyMap(savedState.instances)
s.stateCheckpoints = append(s.stateCheckpoints, StateCheckpoint{
Session: &savedState,
Events: events,
})
}
// GetFormerState iterates backwards over the states, so index == -1 means the last state and so on.
func (s *Session) GetFormerState(index int) *Session {
if index == 0 {
return s
}
index = len(s.stateCheckpoints) + index
if index >= len(s.stateCheckpoints) {
return nil
}
return s.stateCheckpoints[index].Session
}
//
// Game State Functions
//
// GetGameState returns the current game state.
func (s *Session) GetGameState() GameState {
player := s.GetActor(PlayerActorID)
if player.HP == 0 {
return GameStateGameOver
}
return s.state
}
// SetGameState sets the game state and applies all needed setups for the new state to be valid.
func (s *Session) SetGameState(state GameState) {
s.state = state
switch s.state {
case GameStateFight:
s.SetupFight()
case GameStateRandom:
s.LetTellerDecide()
case GameStateMerchant:
s.SetupMerchant()
}
}
// SetEvent changes the active event, but won't set the game state to EVENT. So this can be used
// to set the next event even before a fight or merchant interaction is over.
func (s *Session) SetEvent(id string) {
s.currentEvent = id
if _, ok := s.resources.Events[id]; ok {
s.eventHistory = append(s.eventHistory, id)
_, _ = s.resources.Events[id].OnEnter.Call(CreateContext("type_id", id))
} else {
s.log.Println("Event not found:", id)
s.currentEvent = ""
// If we can't find the event, we just go to the next state
if s.state == GameStateEvent {
s.SetGameState(GameStateRandom)
}
}
}
// GetEventID returns the id of the current event.
func (s *Session) GetEventID() string {
return s.currentEvent
}
// GetEvent returns the event definition of the current event. Will be nil if no event is present.
// It is not allowed to change the Event data, as this points to the event data created in lua!
func (s *Session) GetEvent() *Event {
if len(s.currentEvent) == 0 {
return nil
}
return s.resources.Events[s.currentEvent]
}
// CleanUpFight resets the fight state.
func (s *Session) CleanUpFight() {
s.currentFight.CurrentPoints = s.pointsPerRound
s.currentFight.Deck = lo.Shuffle(s.GetPlayer().Cards.ToSlice())
s.currentFight.Hand = []string{}
s.currentFight.Exhausted = []string{}
s.currentFight.Used = []string{}
s.currentFight.Round = 0
}
// SetupFight setups the fight state, which means removing all leftover status effects, cleaning the state
// drawing the initial hand size and trigger the first wave of OnPlayerTurn callbacks.
//
// Additionally, this will create a save file as this is a clean state to save.
func (s *Session) SetupFight() {
s.RemoveAllStatusEffects()
s.CleanUpFight()
s.PlayerDrawCard(DrawSize)
// Trigger OnPlayerTurn callbacks
TriggerCallbackSimple(s, CallbackOnPlayerTurn, TriggerAll, nil)
// Save after each fight
{
s.Log(LogTypeSuccess, "Session saving...")
save, err := s.GobEncode()
if err != nil {
s.log.Println("Error saving file:", err)
} else {
if err := fs.WriteFile("./session.save", save); err != nil {
s.log.Println("Error saving file:", save)
}
}
}
}
// GetFight returns the fight state. This will return a fight state even if no fight is active at the moment.
func (s *Session) GetFight() FightState {
return s.currentFight
}
// GetStagesCleared returns the amount of stages cleared so far. Each fight represent a stage.
func (s *Session) GetStagesCleared() int {
return s.stagesCleared
}
// GetPointsPerRound returns the amount of action points the player gets each round.
func (s *Session) GetPointsPerRound() int {
return s.pointsPerRound
}
// SetPointsPerRound sets the amount of action points the player gets each round.
func (s *Session) SetPointsPerRound(points int) {
s.pointsPerRound = points
}
// FinishPlayerTurn signals that the player is done with its turn. All enemies act now, status effects are
// evaluated, if the fight is over is checked and if not this will advance to the next round and draw cards
// for the player.
func (s *Session) FinishPlayerTurn() {
// Enemies are allowed to act.
s.EnemyTurn()
// Turn over so we remove all dead status effects.
var removeStatus []string
instanceKeys := lo.Keys(s.instances)
for _, guid := range instanceKeys {
switch instance := s.instances[guid].(type) {
case StatusEffectInstance:
// TODO: investigate why this was here
// if instance.Owner == PlayerActorID && instance.RoundEntered == s.currentFight.Round {
// continue
// }
se := s.resources.StatusEffects[instance.TypeID]
// If it can decay we reduce rounds.
if se.Decay != DecayNone {
instance.RoundsLeft -= 1
s.instances[guid] = instance
}
// Enemy StatusEffect OnTurn were already done in EnemyTurn(). We only let
// the player owned ones turn now.
if instance.Owner == PlayerActorID {
if _, err := s.GetStatusEffect(guid).Callbacks[CallbackOnTurn].Call(CreateContext("type_id", instance.TypeID, "guid", guid, "owner", instance.Owner, "round", s.currentFight.Round, "stacks", instance.Stacks)); err != nil {
s.logLuaError(CallbackOnTurn, instance.TypeID, err)
}
}
switch se.Decay {
// Decay stacks by one and re-set rounds if stacks left.
case DecayOne:
if instance.RoundsLeft <= 0 {
instance.Stacks -= 1
instance.RoundsLeft = se.Rounds
s.instances[guid] = instance
if instance.Stacks <= 0 {
removeStatus = append(removeStatus, guid)
}
}
// Remove all.
case DecayAll:
if instance.RoundsLeft <= 0 {
removeStatus = append(removeStatus, guid)
}
}
}
}
for i := range removeStatus {
s.RemoveStatusEffect(removeStatus[i])
}
if s.FinishFight() {
return
}
// Advance to new Round
s.currentFight.CurrentPoints = s.pointsPerRound
s.currentFight.Round += 1
s.currentFight.Used = append(s.currentFight.Used, s.currentFight.Hand...)
s.currentFight.Hand = []string{}
s.PlayerDrawCard(DrawSize)
// Trigger OnPlayerTurn callbacks
TriggerCallbackSimple(s, CallbackOnPlayerTurn, TriggerAll, nil)
}
// EnemyTurn lets all enemies act. This will also trigger the OnTurn callbacks of all status effects and
// artifacts. If a status effect or artifact returns true, the enemy turn will be skipped. This is used
// for example by the "FEAR" status effect.
func (s *Session) EnemyTurn() {
for k, v := range s.actors {
if k == PlayerActorID || v.IsNone() {
continue
}
if enemy, ok := s.resources.Enemies[v.TypeID]; ok {
skipTurn := false
s.TraverseArtifactsStatus(append(v.Artifacts.ToSlice(), v.StatusEffects.ToSlice()...),
func(instance ArtifactInstance, artifact *Artifact) {
res, err := artifact.Callbacks[CallbackOnTurn].Call(CreateContext("type_id", artifact.ID, "guid", instance.GUID, "owner", instance.Owner, "round", s.GetFightRound()))
if err != nil {
s.logLuaError(CallbackOnTurn, artifact.ID, err)
} else if skip, ok := res.(bool); ok && skip {
skipTurn = true
}
},
func(instance StatusEffectInstance, statusEffect *StatusEffect) {
res, err := statusEffect.Callbacks[CallbackOnTurn].Call(CreateContext("type_id", statusEffect.ID, "guid", instance.GUID, "owner", instance.Owner, "round", s.GetFightRound(), "stacks", instance.Stacks))
if err != nil {
s.logLuaError(CallbackOnTurn, statusEffect.ID, err)
} else if skip, ok := res.(bool); ok && skip {
skipTurn = true
}
},
)
// An effect like FEAR aborted the turn for this actor.
if skipTurn {
continue
}
if _, err := enemy.Callbacks[CallbackOnTurn].Call(CreateContext("type_id", v.TypeID, "guid", k, "round", s.currentFight.Round)); err != nil {
s.logLuaError(CallbackOnTurn, v.TypeID, err)
}
}
}
}
// FinishFight tries to finish the fight. This will return true if the fight is really over.
func (s *Session) FinishFight() bool {
if s.GetOpponentCount(PlayerActorID) == 0 {
s.currentFight.Description = ""
s.stagesCleared += 1
s.CleanUpFight()
s.RemoveAllStatusEffects()
// If an event is already set we switch to it
if len(s.currentEvent) > 0 {
s.SetGameState(GameStateEvent)
} else {
s.SetGameState(GameStateRandom)
}
// Trigger HookNextFightEnd
s.TriggerHooks(HookNextFightEnd)
}
return false
}
// FinishEvent finishes an event with the given choice. If the game state is not in the EVENT state this
// does nothing.
func (s *Session) FinishEvent(choice int) {
if len(s.currentEvent) == 0 || s.state != GameStateEvent {
return
}
s.RemoveNonPlayer()
event := s.resources.Events[s.currentEvent]
s.currentEvent = ""
// If choice was selected and valid we try to use the next game state from the choice.
if choice >= 0 && choice < len(event.Choices) {
nextState, _ := event.Choices[choice].Callback(CreateContext("type_id", event.ID, "choice", choice+1))
// If the choice dictates a new state we take that
if nextState != nil {
if len(nextState.(string)) > 0 {
s.SetGameState(GameState(nextState.(string)))
} else {
s.SetGameState(GameStateRandom)
}
_, _ = event.OnEnd.Call(CreateContext("type_id", event.ID, "choice", choice+1))
return
}
// Otherwise we allow OnEnd to dictate the new state
nextState, _ = event.OnEnd.Call(CreateContext("type_id", event.ID, "choice", choice+1))
if nextState != nil && len(nextState.(string)) > 0 {
s.SetGameState(GameState(nextState.(string)))
} else {
s.SetGameState(GameStateRandom)
}
return
}
nextState, _ := event.OnEnd.Call(CreateContext("type_id", event.ID, "choice", nil))
if nextState != nil && len(nextState.(string)) > 0 {
s.SetGameState(GameState(nextState.(string)))
} else {
s.SetGameState(GameStateRandom)
}
}
// SetFightDescription sets the description of the fight.
func (s *Session) SetFightDescription(description string) {
s.currentFight.Description = description
}
// GetFightRound returns the current round of the fight.
func (s *Session) GetFightRound() int {
return s.currentFight.Round
}
// HadEvent checks if the given event already happened in this run.
func (s *Session) HadEvent(id string) bool {
return lo.Contains(s.eventHistory, id)
}
// HadEvents checks if the given events already happened in this run.
func (s *Session) HadEvents(ids []string) bool {
return lo.Every(s.eventHistory, ids)
}
// HadEventsAny checks if at least one of the given events already happened in this run.
func (s *Session) HadEventsAny(ids []string) bool {
return lo.Some(s.eventHistory, ids)
}
// GetEventHistory returns the ordered list of all events encountered so far.
func (s *Session) GetEventHistory() []string {
return s.eventHistory
}
func (s *Session) GetEventChoiceDescription(i int) string {
event := s.GetEvent()
if event == nil || i < 0 || i >= len(event.Choices) {
return ""
}
if event.Choices[i].DescriptionFn == nil {
return event.Choices[i].Description
}
res, err := event.Choices[i].DescriptionFn.Call(CreateContext("type_id", event.ID, "choice", i))
if err != nil {
s.logLuaError("DescriptionFn", event.ID, err)
return event.Choices[i].Description
}
if res, ok := res.(string); ok {
return res
}
s.logLuaError("DescriptionFn", event.ID, errors.New("didn't return a string"))
return event.Choices[i].Description
}
//
// Merchant
//
// SetupMerchant sets up the merchant, which means generating a new face, text and initial wares.
func (s *Session) SetupMerchant() {
s.merchant.Artifacts = nil
s.merchant.Cards = nil
s.merchant.Face = faces.Global.GenRand()
s.merchant.Text = gen.GetRandom("merchant_lines")
for i := 0; i < 3; i++ {
s.AddMerchantArtifact()
s.AddMerchantCard()
}
}
// LeaveMerchant finishes the merchant state and lets the storyteller decide what to do next. If an event is still set we switch to it.
func (s *Session) LeaveMerchant() {
if s.currentEvent != "" {
s.SetGameState(GameStateEvent)
return
}
s.SetGameState(GameStateRandom)
}
// GetMerchant return the merchant state.
func (s *Session) GetMerchant() MerchantState {
return s.merchant
}
// GetMerchantGoldMax returns what the max cost of a artifact or card is that the merchant might offer.
func (s *Session) GetMerchantGoldMax() int {
return 180 + s.stagesCleared*30
}
func (s *Session) PushRandomHistory(id string) {
s.randomHistory = append([]string{id}, s.randomHistory...)
if len(s.randomHistory) > 10 {
s.randomHistory = s.randomHistory[:10]
}
}
// GetRandomArtifact returns the type id of a random artifact with a price lower than the given value.
func (s *Session) GetRandomArtifact(maxGold int) string {
possible := lo.Filter(lo.Values(s.resources.Artifacts), func(item *Artifact, index int) bool {
return item.Price > 0 && item.Price < maxGold
})
possibleNoDupes := lo.Filter(possible, func(item *Artifact, index int) bool {
return !lo.Contains(s.randomHistory, item.ID)
})
if len(possible) > 0 {
var chosen string
if len(possibleNoDupes) > 0 {
chosen = lo.Shuffle(possibleNoDupes)[0].ID
} else {
chosen = lo.Shuffle(possible)[0].ID
}
s.PushRandomHistory(chosen)
return chosen
}
return ""
}
// GetRandomCard returns the type id of a random card with a price lower than the given value.
func (s *Session) GetRandomCard(maxGold int) string {
possible := lo.Filter(lo.Values(s.resources.Cards), func(item *Card, index int) bool {
return item.Price > 0 && item.Price < maxGold
})
possibleNoDupes := lo.Filter(possible, func(item *Card, index int) bool {
return !lo.Contains(s.randomHistory, item.ID)
})
if len(possible) > 0 {
var chosen string
if len(possibleNoDupes) > 0 {
chosen = lo.Shuffle(possibleNoDupes)[0].ID
} else {
chosen = lo.Shuffle(possible)[0].ID
}
s.PushRandomHistory(chosen)
return chosen
}
return ""
}
// AddMerchantArtifact adds another artifact to the wares of the merchant.
func (s *Session) AddMerchantArtifact() {
if val := s.GetRandomArtifact(s.GetMerchantGoldMax()); len(val) > 0 {
// Don't add duplicates
if lo.SomeBy(s.GetPlayer().Artifacts.ToSlice(), func(guid string) bool {
a, _ := s.GetArtifact(guid)
return a.ID == val
}) {
return
}
s.merchant.Artifacts = append(s.merchant.Artifacts, val)
}
}
// AddMerchantCard adds another card to the wares of the merchant.
func (s *Session) AddMerchantCard() {
if val := s.GetRandomCard(s.GetMerchantGoldMax()); len(val) > 0 {
s.merchant.Cards = append(s.merchant.Cards, val)
}
}
// PlayerBuyCard buys the card with the given type id. The card needs to be in the wares of the merchant.
func (s *Session) PlayerBuyCard(t string) bool {
if !lo.Contains(s.merchant.Cards, t) {
return false
}
card, _ := s.GetCard(t)
if s.GetPlayer().Gold < card.Price {
return false
}
s.UpdatePlayer(func(actor *Actor) bool {
actor.Gold -= card.Price
return true
})
firstFound := false
s.merchant.Cards = lo.Filter(s.merchant.Cards, func(item string, index int) bool {
if firstFound {
return true
}
isType := item == t
if isType {
firstFound = true
return false
}
return true
})
s.GiveCard(card.ID, PlayerActorID)
return true
}
// PlayerBuyArtifact buys the artifact with the given type id. The artifact needs to be in the wares of the merchant.
func (s *Session) PlayerBuyArtifact(t string) bool {
if !lo.Contains(s.merchant.Artifacts, t) {
return false
}
art, _ := s.GetArtifact(t)
if s.GetPlayer().Gold < art.Price {
return false
}
s.UpdatePlayer(func(actor *Actor) bool {
actor.Gold -= art.Price
return true
})
firstFound := false
s.merchant.Artifacts = lo.Filter(s.merchant.Artifacts, func(item string, index int) bool {
if firstFound {
return true
}
isType := item == t
if isType {
firstFound = true
return false
}
return true
})
s.GiveArtifact(art.ID, PlayerActorID)
return true
}
//
// StoryTeller
//
// ActiveTeller returns the active storyteller. The storyteller is responsible for deciding what enemies or events
// the player will encounter next.
func (s *Session) ActiveTeller() *StoryTeller {
teller := lo.Filter(lo.Values(s.resources.StoryTeller), func(teller *StoryTeller, index int) bool {
res, err := teller.Active(CreateContext("type_id", teller.ID))
if err != nil {
s.logLuaError("Active", teller.ID, err)
return false
}
if val, ok := res.(float64); ok {
return val > 0
}
return false
})
if len(teller) == 0 {
s.log.Printf("No active teller found!")
return nil
}
slices.SortFunc(teller, func(a, b *StoryTeller) int {
aOrder, _ := a.Active(CreateContext("type_id", a.ID))
bOrder, _ := b.Active(CreateContext("type_id", b.ID))
if aOrder.(float64) > bOrder.(float64) {
return 1
}
if aOrder.(float64) < bOrder.(float64) {
return -1
}
return 0
})
return teller[0]
}
// LetTellerDecide lets the currently active storyteller decide what the next game state will be.
func (s *Session) LetTellerDecide() {
active := s.ActiveTeller()
if active == nil {
s.log.Printf("No active teller found! Can't decide")
return
}
res, err := active.Decide(CreateContext("type_id", active.ID))
if err != nil {
s.logLuaError("Decide", active.ID, err)
return
}
if val, ok := res.(string); ok {
s.SetGameState(GameState(val))
} else {
s.logLuaError("Decide", active.ID, errors.New("return wasn't a game state"))
}
}
//
// Instances
//
// GetInstances returns all instances in the session.
func (s *Session) GetInstances() []string {
return lo.Keys(s.instances)
}
// GetInstance returns an instance by guid. An instance is a CardInstance or ArtifactInstance.
func (s *Session) GetInstance(guid string) any {
return s.instances[guid]
}
// TraverseArtifactsStatus traverses all artifacts and status effects in the session and calls the given functions
// for each instance. The instances are sorted by their order value. The order value is based on the order that is
// specified in the game data. This allows the game data to control the order of effects.
func (s *Session) TraverseArtifactsStatus(guids []string, artifact func(instance ArtifactInstance, artifact *Artifact), status func(instance StatusEffectInstance, statusEffect *StatusEffect)) {
sort.SliceStable(guids, func(i, j int) bool {
oa := ui.Max(s.GetArtifactOrder(guids[i]), s.GetStatusEffectOrder(guids[i]))
ob := ui.Max(s.GetArtifactOrder(guids[j]), s.GetStatusEffectOrder(guids[j]))
return oa > ob
})
for _, id := range guids {
instance, ok := s.instances[id]
if !ok {
continue
}
switch instance := instance.(type) {
case ArtifactInstance:
// Fetch the backing definition of the type
art, ok := s.resources.Artifacts[instance.TypeID]
if !ok {
continue
}
artifact(instance, art)
case StatusEffectInstance:
// Fetch the backing definition of the type
se, ok := s.resources.StatusEffects[instance.TypeID]
if !ok {
continue
}
status(instance, se)
}
}
}
//
// Status Effect Functions
//
// GetStatusEffectOrder returns the order value of a status effect by guid.
func (s *Session) GetStatusEffectOrder(guid string) int {
// Try as type id
if e, ok := s.resources.StatusEffects[guid]; ok {
return e.Order
}
instance, ok := s.instances[guid]
if !ok {
return 0
}
switch instance := instance.(type) {
case StatusEffectInstance:
if e, ok := s.resources.StatusEffects[instance.TypeID]; ok {
return e.Order
}
}
return 0
}
// GetStatusEffect returns status effect by guid.
func (s *Session) GetStatusEffect(guid string) *StatusEffect {
// Try as type id
if e, ok := s.resources.StatusEffects[guid]; ok {
return e
}
instance, ok := s.instances[guid]
if !ok {
return nil
}
switch instance := instance.(type) {
case StatusEffectInstance:
if e, ok := s.resources.StatusEffects[instance.TypeID]; ok {
return e
}
}
return nil
}
// GetStatusEffectState returns the rendered state of the status effect.
func (s *Session) GetStatusEffectState(guid string) string {
status := s.GetStatusEffect(guid)
if status == nil {
return ""
}
instance := s.GetStatusEffectInstance(guid)
if status.State == nil {
return status.Description
}
res, err := status.State.Call(CreateContext("type_id", status.ID, "guid", guid, "stacks", instance.Stacks, "owner", instance.Owner))
if err != nil {
s.logLuaError("State", instance.TypeID, err)
}
if res == nil {
return status.Description
}
return res.(string)
}
// GetStatusEffectInstance returns status effect instance by guid.
func (s *Session) GetStatusEffectInstance(guid string) StatusEffectInstance {
if val, ok := s.instances[guid].(StatusEffectInstance); ok {
return val
}
return StatusEffectInstance{}
}
// RemoveAllStatusEffects clears all present status effects.
func (s *Session) RemoveAllStatusEffects() {
var clean []string
for guid, v := range s.instances {
if _, ok := v.(StatusEffectInstance); ok {
clean = append(clean, guid)
}
}
for i := range clean {
delete(s.instances, clean[i])
}
for guid, actor := range s.actors {
actor.StatusEffects = NewStringSet()
s.actors[guid] = actor
}
}
// GiveStatusEffect gives the owner a status effect of a certain type. Status effects are singleton per actor,
// so if the actor already has the status effect the stacks will be increased.
func (s *Session) GiveStatusEffect(typeId string, owner string, stacks int) string {
if len(owner) == 0 {
s.log.Println("Error: trying to give status effect without owner!")
return ""
}
status := s.resources.StatusEffects[typeId]
if status == nil {
return ""
}
if _, ok := s.actors[owner]; !ok {
return ""
}
// TODO: This should always be either 0 or 1 len, so the logic down below is a bit meh.
same := lo.Filter(s.actors[owner].StatusEffects.ToSlice(), func(guid string, index int) bool {
instance, ok := s.instances[guid].(StatusEffectInstance)
if !ok {
return false
}
return instance.TypeID == typeId
})
if len(same) > 1 {
panic("Error: status effect duplicate!")
}
// If it can't stack we delete all existing instances
if !status.CanStack {
for i := range same {
s.RemoveStatusEffect(same[i])
}
} else if len(same) > 0 {
// Increase stack and re-set rounds left
instance := s.instances[same[0]].(StatusEffectInstance)
instance.Stacks += stacks
instance.RoundsLeft = status.Rounds
s.instances[same[0]] = instance
if _, err := status.Callbacks[CallbackOnStatusStack].Call(CreateContext("type_id", typeId, "guid", same[0], "owner", owner, "stacks", instance.Stacks)); err != nil {
s.logLuaError(CallbackOnStatusStack, instance.TypeID, err)
}
return instance.GUID
}
instance := StatusEffectInstance{
TypeID: typeId,
GUID: NewGuid("STATUS"),
Owner: owner,
RoundsLeft: status.Rounds,
Stacks: stacks,
RoundEntered: s.currentFight.Round,
}
s.instances[instance.GUID] = instance
s.actors[owner].StatusEffects.Add(instance.GUID)
// Call OnStatusAdd callback for the new instance
_, _ = status.Callbacks[CallbackOnStatusAdd].Call(CreateContext("type_id", typeId, "guid", instance.GUID))
return instance.GUID
}
// RemoveStatusEffect removes a status effect by guid.
func (s *Session) RemoveStatusEffect(guid string) {
instance, ok := s.instances[guid].(StatusEffectInstance)
if !ok {
return
}
if _, err := s.resources.StatusEffects[instance.TypeID].Callbacks[CallbackOnStatusRemove].Call(CreateContext("type_id", instance.TypeID, "guid", guid, "owner", instance.Owner)); err != nil {
s.logLuaError(CallbackOnStatusRemove, instance.TypeID, err)
}
if actor, ok := s.actors[instance.Owner]; ok {
actor.StatusEffects.Remove(instance.GUID)
}
delete(s.instances, guid)
}
// GetActorStatusEffects returns the guids of all the status effects a certain actor owns.
func (s *Session) GetActorStatusEffects(guid string) []string {
if actor, ok := s.actors[guid]; ok {
return actor.StatusEffects.ToSlice()
}
return []string{}
}
// AddStatusEffectStacks increases the stacks of a certain status effect by guid.
func (s *Session) AddStatusEffectStacks(guid string, stacks int) {
instance, ok := s.instances[guid].(StatusEffectInstance)
if !ok {
return
}
instance.Stacks += stacks
if instance.Stacks <= 0 {
s.RemoveStatusEffect(guid)
} else {
s.instances[guid] = instance
}
}
// SetStatusEffectStacks sets the stacks of a certain status effect by guid.
func (s *Session) SetStatusEffectStacks(guid string, stacks int) {
instance, ok := s.instances[guid].(StatusEffectInstance)
if !ok {
return
}
instance.Stacks = stacks
if instance.Stacks <= 0 {
s.RemoveStatusEffect(guid)
} else {
s.instances[guid] = instance
}
}
//
// Artifact Functions
//
// GetArtifactOrder returns the order value of a certain artifact by guid.
func (s *Session) GetArtifactOrder(guid string) int {
artInstance, ok := s.instances[guid]
if !ok {
return 0
}
switch artInstance := artInstance.(type) {
case ArtifactInstance:
if art, ok := s.resources.Artifacts[artInstance.TypeID]; ok {
return art.Order
}
}
return 0
}
// GetArtifacts returns all artifacts owned by a actor.
func (s *Session) GetArtifacts(owner string) []string {
guids := s.actors[owner].Artifacts.ToSlice()
sort.Strings(guids)
return guids
}
// GetArtifact returns an artifact, and instance by guid or type id. If a type id is given
// only the Artifact will be returned.
func (s *Session) GetArtifact(guid string) (*Artifact, ArtifactInstance) {
// check if guid is actually typeId
if val, ok := s.resources.Artifacts[guid]; ok {
return val, ArtifactInstance{}
}
artInstance, ok := s.instances[guid]
if !ok {
return nil, ArtifactInstance{}
}
switch artInstance := artInstance.(type) {
case ArtifactInstance:
if art, ok := s.resources.Artifacts[artInstance.TypeID]; ok {
return art, artInstance
}
}
return nil, ArtifactInstance{}
}
// GiveArtifact gives an artifact to an actor.
func (s *Session) GiveArtifact(typeId string, owner string) string {
if _, ok := s.resources.Artifacts[typeId]; !ok {
return ""
}
instance := ArtifactInstance{
TypeID: typeId,
GUID: NewGuid("ARTIFACT"),
Owner: owner,
}
s.instances[instance.GUID] = instance
s.actors[owner].Artifacts.Add(instance.GUID)
// Call OnPickUp callback for the new instance
if _, err := s.resources.Artifacts[typeId].Callbacks[CallbackOnPickUp].Call(CreateContext("type_id", typeId, "guid", instance.GUID, "owner", owner)); err != nil {
s.logLuaError(CallbackOnPickUp, instance.TypeID, err)
}
s.PushState(map[StateEvent]any{
StateEventArtifactAdded: StateEventArtifactAddedData{
Owner: owner,
TypeID: typeId,
GUID: instance.GUID,
},
})
return instance.GUID
}
// RemoveArtifact removes an artifact by guid.
func (s *Session) RemoveArtifact(guid string) {
instance := s.instances[guid].(ArtifactInstance)
if _, err := s.resources.Artifacts[instance.TypeID].Callbacks[CallbackOnRemove].Call(CreateContext("type_id", instance.TypeID, "guid", guid, "owner", instance.Owner)); err != nil {
s.logLuaError(CallbackOnRemove, instance.TypeID, err)
}
s.actors[instance.Owner].Artifacts.Remove(instance.GUID)
delete(s.instances, guid)
s.PushState(map[StateEvent]any{
StateEventArtifactRemoved: StateEventArtifactRemovedData{
Owner: instance.Owner,
TypeID: instance.TypeID,
GUID: instance.GUID,
},
})
}
//
// Card Functions
//
// GetCard returns a card, and instance by guid or type id. If a type id is given
// only the Card will be returned. If the card is not found, nil is returned.
func (s *Session) GetCard(guid string) (*Card, CardInstance) {
// check if guid is actually typeId
if val, ok := s.resources.Cards[guid]; ok {
return val, CardInstance{}
}
cardInstance, ok := s.instances[guid]
if !ok {
return nil, CardInstance{}
}
switch cardInstance := cardInstance.(type) {
case CardInstance:
if card, ok := s.resources.Cards[cardInstance.TypeID]; ok {
return card, cardInstance
}
}
return nil, CardInstance{}
}
// GiveCard gives a card to an actor. Returns the guid of the new card.
func (s *Session) GiveCard(typeId string, owner string) string {
if _, ok := s.resources.Cards[typeId]; !ok {
return ""
}
instance := CardInstance{
TypeID: typeId,
GUID: NewGuid("CARD"),
Owner: owner,
}
s.instances[instance.GUID] = instance
s.actors[owner].Cards.Add(instance.GUID)
s.PushState(map[StateEvent]any{
StateEventCardAdded: StateEventCardAddedData{
Owner: owner,
TypeID: typeId,
GUID: instance.GUID,
},
})
return instance.GUID
}
// RemoveCard removes a card by guid.
func (s *Session) RemoveCard(guid string) {
instance := s.instances[guid].(CardInstance)
s.actors[instance.Owner].Cards.Remove(instance.GUID)
delete(s.instances, guid)
s.PushState(map[StateEvent]any{
StateEventCardRemoved: StateEventCardRemovedData{
Owner: instance.Owner,
TypeID: instance.TypeID,
GUID: instance.GUID,
},
})
}
// CastCard calls the OnCast callback for a card, casting it.
func (s *Session) CastCard(guid string, target string) bool {
if card, instance := s.GetCard(guid); card != nil {
res, err := card.Callbacks[CallbackOnCast].Call(CreateContext("type_id", card.ID, "guid", guid, "caster", instance.Owner, "target", target, "level", instance.Level))
if err != nil {
s.logLuaError(CallbackOnCast, instance.TypeID, err)
}
if val, ok := res.(bool); ok {
if !val {
return false
}
TriggerCallbackSimple(s, CallbackOnActorDidCast, TriggerAll, EmptyContext, CreateContext("type_id", card.ID, "guid", guid, "caster", instance.Owner, "target", target, "level", instance.Level, "tags", card.Tags))
return true
}
}
return true
}
// GetCards returns all cards owned by a actor.
func (s *Session) GetCards(owner string) []string {
guids := s.actors[owner].Cards.ToSlice()
sort.Strings(guids)
return guids
}
// GetCardState returns the state of a card.
func (s *Session) GetCardState(guid string) string {
card, instance := s.GetCard(guid)
if card == nil {
return ""
}
if card.State == nil {
return card.Description
}
res, err := card.State.Call(CreateContext("type_id", card.ID, "guid", guid, "level", instance.Level, "owner", instance.Owner))
if err != nil {
s.logLuaError("State", instance.TypeID, err)
}
if res == nil {
return card.Description
}
return res.(string)
}
// PlayerCastHand casts a card from the players hand.
func (s *Session) PlayerCastHand(i int, target string) error {
if i >= len(s.currentFight.Hand) {
return errors.New("hand empty")
}
cardId := s.currentFight.Hand[i]
// Only cast a card if castable and points are available and subtract them.
card, _ := s.GetCard(cardId)
if card != nil {
if !card.Callbacks[CallbackOnCast].Present() {
return errors.New("card is not castable")
}
if s.currentFight.CurrentPoints < card.PointCost {
return errors.New("not enough points")
}
s.currentFight.CurrentPoints -= card.PointCost
} else {
return errors.New("card not exists")
}
// Remove from hand.
s.currentFight.Hand = lo.Reject(s.currentFight.Hand, func(item string, index int) bool {
return index == i
})
// Cast and exhaust if needed.
didCast := s.CastCard(cardId, target)
if didCast {
if card.DoesExhaust {
s.currentFight.Exhausted = append(s.currentFight.Exhausted, cardId)
} else if card.DoesConsume {
s.RemoveCard(cardId)
} else {
s.currentFight.Used = append(s.currentFight.Used, cardId)
}
}
s.FinishFight()
return nil
}
// PlayerDrawCard draws a card from the deck.
func (s *Session) PlayerDrawCard(amount int) {
for i := 0; i < amount; i++ {
// Shuffle used back in
if len(s.currentFight.Deck) == 0 && len(s.currentFight.Used) > 0 {
s.currentFight.Deck = lo.Shuffle(s.currentFight.Used)
s.currentFight.Used = []string{}
}
// If nothing left don't draw
if len(s.currentFight.Deck) == 0 {
break
}
s.currentFight.Hand = append(s.currentFight.Hand, s.currentFight.Deck[0])
s.currentFight.Deck = lo.Drop(s.currentFight.Deck, 1)
}
}
// PlayerGiveActionPoints gives the player action points.
func (s *Session) PlayerGiveActionPoints(amount int) {
s.currentFight.CurrentPoints += amount
}
// BuyUpgradeCard upgrades a card by its GUID.
func (s *Session) BuyUpgradeCard(guid string) bool {
card, instance := s.GetCard(guid)
if instance.IsNone() || card.MaxLevel == 0 || instance.Level == card.MaxLevel {
return false
}
if s.GetPlayer().Gold < DefaultUpgradeCost {
return false
}
s.UpdatePlayer(func(actor *Actor) bool {
actor.Gold -= DefaultUpgradeCost
return true
})
instance.Level += 1
s.instances[guid] = instance
return true
}
// BuyRemoveCard removes a card by its GUID.
func (s *Session) BuyRemoveCard(guid string) bool {
_, instance := s.GetCard(guid)
if instance.IsNone() {
return false
}
if s.GetPlayer().Gold < DefaultRemoveCost {
return false
}
s.UpdatePlayer(func(actor *Actor) bool {
actor.Gold -= DefaultUpgradeCost
return true
})
s.RemoveCard(guid)
return true
}
// UpgradeCard upgrades a card by its GUID.
func (s *Session) UpgradeCard(guid string) bool {
card, instance := s.GetCard(guid)
if instance.IsNone() || card.MaxLevel == 0 || instance.Level == card.MaxLevel {
return false
}
instance.Level += 1
s.instances[guid] = instance
return true
}
// UpgradeRandomCard upgrades a random card of the given owner.
func (s *Session) UpgradeRandomCard(owner string) bool {
upgradeable := lo.Filter(s.GetActor(owner).Cards.ToSlice(), func(item string, index int) bool {
card, instance := s.GetCard(item)
if instance.IsNone() {
return false
}
return instance.Level != card.MaxLevel
})
if len(upgradeable) == 0 {
return false
}
return s.UpgradeCard(lo.Shuffle(upgradeable)[0])
}
//
// Damage & Heal Function
//
// DealDamage deals damage to a target. If flat is true it will not trigger any callbacks which modify the damage.
func (s *Session) DealDamage(source string, card string, target string, damage int, flat bool) int {
if _, ok := s.actors[source]; !ok {
return 0
}
val, ok := s.actors[target]
if !ok {
return 0
}
// If not flat we will modify the damage based on the OnDamageCalc callbacks.
if !flat {
reducer := func(cur float64, val float64) float64 {
return val
}
damage = int(TriggerCallbackReduce[float64](
s,
CallbackOnDamageCalc,
TriggerAll,
reducer,
float64(damage),
"damage",
CreateContext("source", source, "card", card, "target", target, "damage", damage)),
)
}
if source == PlayerActorID {
s.Log(LogTypeSuccess, fmt.Sprintf("You hit the enemy for %d damage", damage))
} else if target == PlayerActorID {
s.Log(LogTypeDanger, fmt.Sprintf("You took %d damage", damage))
} else {
s.Log(LogTypeSuccess, fmt.Sprintf("%s took %d damage", val.Name, damage))
}
// Negative damage aka heal is not allowed!
if damage < 0 {
return 0
}
// Trigger OnDamage callbacks
TriggerCallbackSimple(s, CallbackOnDamage, TriggerAll, CreateContext("source", source, "target", target, "damage", damage))
// Re-fetch actor in case the OnDamage callback triggered some kind of damage or healing.
val = s.actors[target]
hpLeft := lo.Clamp(val.HP-damage, 0, val.MaxHP)
// Remove dead non-player actor
if target != PlayerActorID && hpLeft == 0 {
s.PushState(map[StateEvent]any{
StateEventDeath: StateEventDeathData{
Source: source,
Target: target,
Damage: damage,
},
})
s.Log(LogTypeSuccess, fmt.Sprintf("%s died and dropped %d gold!", val.Name, val.Gold))
s.GivePlayerGold(val.Gold)
// Trigger OnActorDie callbacks
TriggerCallbackSimple(s, CallbackOnActorDie, TriggerAll, CreateContext("source", source, "target", target, "damage", damage))
s.RemoveActor(target)
} else {
s.PushState(map[StateEvent]any{
StateEventDamage: StateEventDamageData{
Source: source,
Target: target,
Damage: damage,
},
})
s.UpdateActor(target, func(actor *Actor) bool {
actor.HP = hpLeft
return true
})
if target == PlayerActorID && s.GetPlayer().HP == 0 {
s.SetGameState(GameStateGameOver)
}
}
return damage
}
// SimulateDealDamage will simulate damage to a target. If flat is true it will not trigger any callbacks which modify the damage.
func (s *Session) SimulateDealDamage(source string, target string, damage int, flat bool) int {
if _, ok := s.actors[source]; !ok {
return 0
}
_, ok := s.actors[target]
if !ok {
return 0
}
// If not flat we will modify the damage based on the OnDamageCalc callbacks.
if !flat {
reducer := func(cur float64, val float64) float64 {
return val
}
damage = int(TriggerCallbackReduce[float64](
s,
CallbackOnDamageCalc,
TriggerAll,
reducer,
float64(damage),
"damage",
CreateContext("source", source, "target", target, "damage", damage, "simulated", true)),
)
}
// Negative damage aka heal is not allowed!
if damage < 0 {
return 0
}
return damage
}
// DealDamageMulti will deal damage to multiple targets and return the amount of damage dealt to each target.
// If flat is true it will not trigger any OnDamageCalc callbacks which modify the damage.
func (s *Session) DealDamageMulti(source string, card string, targets []string, damage int, flat bool) []int {
return lo.Map(targets, func(guid string, index int) int {
return s.DealDamage(source, card, guid, damage, flat)
})
}
// Heal will heal the target for the given amount from source to target and return the amount healed.
// If flat is true it will not trigger any OnHealCalc callbacks which modify the heal.
func (s *Session) Heal(source string, target string, heal int, flat bool) int {
if val, ok := s.actors[target]; ok {
if !flat {
s.TraverseArtifactsStatus(lo.Flatten([][]string{
s.GetActor(source).Artifacts.ToSlice(),
s.GetActor(target).StatusEffects.ToSlice(),
s.GetActor(source).StatusEffects.ToSlice(),
}),
func(instance ArtifactInstance, art *Artifact) {
res, err := art.Callbacks[CallbackOnHealCalc].Call(CreateContext("type_id", art.ID, "guid", instance.GUID, "source", source, "target", target, "owner", instance.Owner, "heal", heal))
if err != nil {
s.logLuaError(CallbackOnHealCalc, instance.TypeID, err)
} else if res != nil {
if newHeal, ok := res.(float64); ok {
heal = int(newHeal)
}
}
},
func(instance StatusEffectInstance, se *StatusEffect) {
res, err := se.Callbacks[CallbackOnHealCalc].Call(CreateContext("type_id", se.ID, "guid", instance.GUID, "source", source, "target", target, "owner", instance.Owner, "stacks", instance.Stacks, "heal", heal))
if err != nil {
s.logLuaError(CallbackOnHealCalc, instance.TypeID, err)
} else if res != nil {
if newHeal, ok := res.(float64); ok {
heal = int(newHeal)
}
}
},
)
}
if target == PlayerActorID {
s.Log(LogTypeSuccess, fmt.Sprintf("You healed %d damage", heal))
} else {
s.Log(LogTypeDanger, fmt.Sprintf("%s healed %d damage", val.Name, heal))
}
// Negative heal aka damage is not allowed!
if heal < 0 {
heal = 0
}
s.UpdateActor(target, func(actor *Actor) bool {
actor.HP = lo.Clamp(val.HP+heal, 0, val.MaxHP)
return true
})
return heal
}
return 0
}
//
// Actor Functions
//
// GetPlayer returns the player.
func (s *Session) GetPlayer() Actor {
return s.actors[PlayerActorID]
}
// UpdatePlayer updates the player using a update function.
func (s *Session) UpdatePlayer(update func(actor *Actor) bool) {
s.UpdateActor(PlayerActorID, update)
}
// GetActors returns all actors.
func (s *Session) GetActors() []string {
return lo.Keys(s.actors)
}
// GetActor returns an actor.
func (s *Session) GetActor(id string) Actor {
if val, ok := s.actors[id]; ok {
return val
}
return NewActor("")
}
// UpdateActor updates an actor. If the update function returns true the actor will be updated.
func (s *Session) UpdateActor(id string, update func(actor *Actor) bool) {
actor := s.GetActor(id)
if update(&actor) {
s.actors[id] = actor
}
}
// GetActorIntend returns the intend of an actor.
func (s *Session) GetActorIntend(guid string) string {
if enemy := s.GetEnemy(s.actors[guid].TypeID); enemy != nil {
res, err := enemy.Intend.Call(CreateContext("type_id", enemy.ID, "guid", guid, "round", s.currentFight.Round))
if err != nil {
s.logLuaError("Intend", enemy.ID, err)
} else if res, ok := res.(string); ok {
return res
}
}
return ""
}
// ActorAddMaxHP adds max hp to an actor.
func (s *Session) ActorAddMaxHP(id string, val int) {
s.UpdateActor(id, func(actor *Actor) bool {
actor.MaxHP += val
return true
})
}
// ActorAddHP adds HP to an actor.
func (s *Session) ActorAddHP(id string, val int) {
s.UpdateActor(id, func(actor *Actor) bool {
actor.HP += val
return true
})
}
// AddActor adds an actor to the session.
func (s *Session) AddActor(actor Actor) {
s.actors[actor.GUID] = actor
}
// AddActorFromEnemy adds an actor to the session from an enemy base.
func (s *Session) AddActorFromEnemy(id string) string {
if base, ok := s.resources.Enemies[id]; ok {
actor := NewActor(NewGuid(id))
actor.TypeID = id
actor.Name = base.Name
actor.Description = base.Description
actor.HP = base.InitialHP
actor.MaxHP = base.MaxHP
actor.Gold = base.Gold
// Its important we add the actor before any callbacks so that it's instance is available
// to add cards etc. to!
s.AddActor(actor)
if _, err := base.Callbacks[CallbackOnInit].Call(CreateContext("type_id", id, "guid", actor.GUID)); err != nil {
s.logLuaError(CallbackOnInit, actor.TypeID, err)
}
return actor.GUID
}
return ""
}
// RemoveActor removes an actor from the session.
func (s *Session) RemoveActor(id string) {
var deleteInstances []string
for _, val := range s.instances {
switch val := val.(type) {
case CardInstance:
if val.Owner == id {
deleteInstances = append(deleteInstances, id)
}
case ArtifactInstance:
if val.Owner == id {
deleteInstances = append(deleteInstances, id)
}
}
}
// Clear actor owned items
for _, k := range deleteInstances {
delete(s.instances, k)
}
delete(s.actors, id)
}
// RemoveNonPlayer removes all actors that are not the player.
func (s *Session) RemoveNonPlayer() {
var deleteActors []string
for _, a := range s.actors {
if a.GUID != PlayerActorID {
deleteActors = append(deleteActors, a.GUID)
}
}
for _, k := range deleteActors {
delete(s.actors, k)
}
}
// GetOpponentCount returns the number of opponents from the given viewpoint.
func (s *Session) GetOpponentCount(viewpoint string) int {
switch viewpoint {
// From the viewpoint of the player we can have multiple enemies.
case PlayerActorID:
return len(lo.Filter(lo.Keys(s.actors), func(item string, index int) bool {
return item != PlayerActorID
}))
// From the viewpoint of an enemy we only have the player as enemy.
default:
return 1
}
}
// GetOpponentByIndex returns the opponent at the given index from the given viewpoint.
func (s *Session) GetOpponentByIndex(viewpoint string, i int) Actor {
switch viewpoint {
// From the viewpoint of the player we can have multiple enemies.
case PlayerActorID:
if len(s.actors) <= 1 {
return Actor{}
}
ids := lo.Filter(lo.Keys(s.actors), func(guid string, index int) bool {
return guid != PlayerActorID
})
sort.Strings(ids)
if i < 0 || i >= len(ids) {
return Actor{}
}
return s.actors[ids[i]]
// From the viewpoint of an enemy we only have the player as enemy.
default:
return s.actors[PlayerActorID]
}
}
// GetOpponents returns the opponents from the given viewpoint.
func (s *Session) GetOpponents(viewpoint string) []Actor {
return lo.Map(s.GetOpponentGUIDs(viewpoint), func(guid string, index int) Actor {
return s.actors[guid]
})
}
// GetOpponentGUIDs returns the guids of the opponents from the given viewpoint.
func (s *Session) GetOpponentGUIDs(viewpoint string) []string {
switch viewpoint {
// From the viewpoint of the player we can have multiple enemies.
case PlayerActorID:
guids := lo.Filter(lo.Keys(s.actors), func(guid string, index int) bool {
return guid != PlayerActorID
})
sort.Strings(guids)
return guids
// From the viewpoint of an enemy we only have the player as enemy.
default:
return []string{PlayerActorID}
}
}
// GetEnemy returns the enemy with the given type id.
func (s *Session) GetEnemy(typeId string) *Enemy {
return s.resources.Enemies[typeId]
}
//
// Gold
//
// GivePlayerGold gives the player the given amount of gold.
func (s *Session) GivePlayerGold(amount int) {
if amount <= 0 {
return
}
s.UpdatePlayer(func(actor *Actor) bool {
actor.Gold += amount
s.PushState(map[StateEvent]any{
StateEventMoney: StateEventMoneyData{
Target: PlayerActorID,
Money: amount,
},
})
return true
})
}
//
// Hooks
//
// AddHook adds a hook to the session.
func (s *Session) AddHook(hook Hook, callback func()) {
s.hooks[hook] = append(s.hooks[hook], callback)
}
// TriggerHooks triggers all hooks of a certain type.
func (s *Session) TriggerHooks(hook Hook) {
for _, callback := range s.hooks[hook] {
callback()
}
s.hooks[hook] = []func(){}
}
//
// Misc Functions
//
// Log adds a log entry to the session.
func (s *Session) Log(t LogType, msg string) {
s.Logs = append(s.Logs, LogEntry{
Time: time.Now(),
Type: t,
Message: msg,
})
}
// Fetch retrieves a value from the session context.
func (s *Session) Fetch(key string) any {
return s.ctxData[key]
}
// Store stores a value in the session context.
func (s *Session) Store(key string, value any) {
s.ctxData[key] = value
}
// ToSVG creates an SVG representation from the internal state. The returned string is the d2
// representation of the SVG (https://d2lang.com/).
func (s *Session) ToSVG() ([]byte, string, error) {
diag := `
direction: right
resources: Lua Defined Resources {
cards: Cards
artifacts: Artifacts
status: Status Effects
}
instances: Instances {
}
actors: Actors {
}
`
for k, v := range s.actors {
diag += fmt.Sprintf(`
actors.%s: {
text: ||txt
%s
||
}
`, k, fmt.Sprintf("NAME = %s\nHP = %d / %d", v.Name, v.HP, v.MaxHP))
lo.ForEach(v.Cards.ToSlice(), func(item string, index int) {
diag += fmt.Sprintf("actors.%s -> instances.%s\n", k, item)
})
lo.ForEach(v.Artifacts.ToSlice(), func(item string, index int) {
diag += fmt.Sprintf("actors.%s -> instances.%s\n", k, item)
})
lo.ForEach(v.StatusEffects.ToSlice(), func(item string, index int) {
diag += fmt.Sprintf("actors.%s -> instances.%s\n", k, item)
})
}
for i := range s.instances {
switch inst := s.instances[i].(type) {
case ArtifactInstance:
diag += fmt.Sprintf("instances.%s -> %s\n", inst.GUID, "resources.artifacts."+inst.TypeID+": TypeId {style.animated: true}")
case CardInstance:
diag += fmt.Sprintf("instances.%s { \ntext: ||txt\n%s\n||\n}\n", inst.GUID, fmt.Sprintf("Level = %d", inst.Level))
diag += fmt.Sprintf("instances.%s -> %s\n", inst.GUID, "resources.cards."+inst.TypeID+": TypeId {style.animated: true}")
case StatusEffectInstance:
diag += fmt.Sprintf("instances.%s { \ntext: ||txt\n%s\n||\n}\n", inst.GUID, fmt.Sprintf("Stacks = %d\nRounds Left = %d", inst.Stacks, inst.RoundsLeft))
diag += fmt.Sprintf("instances.%s -> %s\n", inst.GUID, "resources.status."+inst.TypeID+": TypeId {style.animated: true}")
}
}
ruler, _ := textmeasure.NewRuler()
defaultLayout := func(ctx context.Context, g *d2graph.Graph) error {
return d2dagrelayout.Layout(ctx, g, nil)
//return d2elklayout.Layout(ctx, g, nil)
}
diagram, _, err := d2lib.Compile(context.Background(), diag, &d2lib.CompileOptions{
Layout: defaultLayout,
Ruler: ruler,
})
if err != nil {
return nil, diag, err
}
out, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: d2svg.DEFAULT_PADDING * 2,
Center: true,
ThemeID: d2themescatalog.TerminalGrayscale.ID,
})
if err != nil {
return nil, diag, err
}
return out, diag, nil
}