KoboldSim/koboldsim/cardtypes.go
Kistaro Windrider 2d16f97314
Remiplement Add to use reflection.
Now it matters whether `FieldLabel`s are spelled correctly, but in return, we don't have to write and maintain that obnoxious `switch` statement.
2024-09-29 12:19:07 -07:00

607 lines
17 KiB
Go

package koboldsim
import (
"errors"
"git.chromaticdragon.app/kistaro/CardSimEngine/cardsim"
)
var (
ErrOptionNotEnabled = errors.New("option not enabled")
ErrPolicyNotEnacted = errors.New("cannot unenact policy that is not enacted")
// ErrUnimplemented and ErrKeepMessaage are "non-errors". They are used
// as special signals that the result needs to be handled in a special way;
// VerbosePolicy uses these to decide when to use the Default instead.
// If these are returned in a context that does not know how to respond
// to them, then they're just errors.
ErrUnimplemented = errors.New("unimplemented policy element")
ErrKeepMessage = errors.New("use the default behavior but this message")
)
type Policy interface {
cardsim.CardOption[*KoboldMine]
// Unenact reverses the previous enactment of this policy.
Unenact(p *Player) error
// LastEnacted informs this Policy which choice was last enacted and
// which index it is under; this allows it to prepare to describe
// itself differently depending on the last selected policy. If no
// option has ever been chosen for this card, the index is -1.
//
// The Card that would present this Policy as an option must use this,
// and provide the active policy that this is a candidate to replace,
LastEnacted(int, Policy)
// Is returns whether this policy is this other policy. This is a strict
// identity equality check, don't do anything clever here.
Is(Policy) bool
}
// A SwitchingCard is an issue card that remembers which option was selected
// previously, and undoes it before doing a different one. It is always valid
// to draw.
type SwitchingCard struct {
// Name contains the name of the card, displayed as its title in the
// action selection menu and in the card detail page itself.
Name cardsim.Message
// Desc contains the event description for the card, displayed in the
// card detail page.
Desc cardsim.Message
// IsUrgent marks a card as urgent. If the player has any urgent cards
// in hand, they cannot act on any non-urgent cards (or permanent actions
// not marked as urgent).
IsUrgent bool
// After is invoked after the player has chosen to act on this card and
// the chosen option has been fully enacted. If the card should be returned
// to the deck, After is responsible for doing this!
//
// If After is not provided, ShuffleIntoBottomHalf is used as a fallback.
//
// The first argument to After is the card itself. This will be type
// *SwitchingCard. It's represented as Card so general "After" functions
// that can be used with multiple card types (for example,
// ShuffleIntoBottomHalf) can be trivially implemented.
//
// If the card cannot be drawn into the hand because it has an IsValid
// check and the check fails, After is invoked with a nil CardOption.
// ShuffleIntoBottomHalf works just fine with a nil argument here.
// (It doesn't care about the CardOption at all.)
After func(Card, *Player, CardOption) error
// IsValid is used to check whether the card can be drawn into the hand.
// If it cannot, After is immediately invoked (whenever the card was
// being drawn, which is probably the "draw" stage of the turn but can
// happen any time if something else causes the player to draw cards) with
// a nil CardOption (because no option was selected) and the SwitchingCard
// does not invoke any option and does not change its active policy.
//
// The first argument to IsValid is the card itself. This will be type
// *SwitchingCard. It's presented via the Card interface to support
// general validity functions that could be used with arbitrary kinds of Card.
IsValid func(Card, *Player) bool
// Policies contains the options the player may choose between. Policy is
// a more specific type than CardOption; a Policy can be un-enacted.
// Unenactment of the previous policy before selecting a new one is the
// core feature of SwitchingCard.
Policies []Policy
// lastPolicy stores the last policy selected for this card. It's used
// extensively by SwitchingCard's logic.
lastPolicy Policy
// ShowUnavailable controls whether options for which Enabled() = false
// should be presented to the player at all. Indexes for "last enacted"
// still refer to the original list, not the shortened one.
ShowUnavailable bool
}
// Title implements Card.
func (s *SwitchingCard) Title(*Player) cardsim.Message {
return s.Name
}
// Urgent implements Card.
func (s *SwitchingCard) Urgent(*Player) bool {
return s.IsUrgent
}
// Drawn implements Card.
func (s *SwitchingCard) Drawn(p *Player) bool {
if s.IsValid != nil && !s.IsValid(s, p) {
err := s.Then(p, nil)
p.ReportError(err) // can't do anything with the error right now
return false
}
return true
}
// EventText implements Card.
func (s *SwitchingCard) EventText(*Player) (cardsim.Message, error) {
return s.Desc, nil
}
// Options implements Card.
func (s *SwitchingCard) Options(player *Player) ([]CardOption, error) {
lastIdx := -1
for i, p := range s.Policies {
if p.Is(s.lastPolicy) {
lastIdx = i
break
}
}
ret := make([]CardOption, 0, len(s.Policies))
for _, p := range s.Policies {
p.LastEnacted(lastIdx, s.lastPolicy)
if s.ShowUnavailable || p.Enabled(player) {
ret = append(ret, p)
}
}
return ret, nil
}
// Then implements Card.
func (s *SwitchingCard) Then(p *Player, o CardOption) error {
var errs cardsim.ErrorCollector
if o != nil {
newPolicy := o.(Policy)
if s.lastPolicy != nil && !newPolicy.Is(s.lastPolicy) {
err := s.lastPolicy.Unenact(p)
if cardsim.IsSeriousError(err) {
return err
}
errs.Add(err)
}
s.lastPolicy = o.(Policy)
}
if s.After != nil {
errs.Add(s.After(s, p, o))
} else {
// Fallback: Shuffle the card back into the bottom half of the deck.
errs.Add(ShuffleIntoBottomHalf(s, p, o))
}
return errs.Emit()
}
// CurrentlyEnacted returns the currently enacted Policy, if any.
func (s *SwitchingCard) CurrentlyEnacted() Policy {
return s.lastPolicy
}
// BasicPolicy is a straightfoward implementation of Policy. If the currently
// enacted option is re-enacted, it refunds the player's action point.
type BasicPolicy struct {
Desc cardsim.Message
UnenactedDesc cardsim.Message
EnactedDesc cardsim.Message
NothingChanged cardsim.Message
Do func(*Player) (cardsim.Message, error)
Undo func(*Player) error
CanDo func(*BasicPolicy, *Player) bool
CurrentlyEnacted bool
LastEnactedPolicy Policy
LastEnactedIdx int
}
// YesWeCan returns true. It's the default value for BasicPolicy.CanDo / BasicPolicy.CanUndo.
func YesWeCan(*BasicPolicy, *Player) bool {
return true
}
// LastEnacted notifies b about the last-enacted policy in its group. It updates
// b.currentlyEnacted accordingly.
func (b *BasicPolicy) LastEnacted(i int, p Policy) {
b.LastEnactedPolicy = p
b.LastEnactedIdx = i
b.CurrentlyEnacted = b.Is(p)
}
// OptionText implements CardOption.
func (b *BasicPolicy) OptionText(*Player) (cardsim.Message, error) {
if b.CurrentlyEnacted {
if b.EnactedDesc == nil {
return nil, ErrUnimplemented
}
return b.EnactedDesc, nil
}
if b.UnenactedDesc == nil {
return nil, ErrUnimplemented
}
return b.UnenactedDesc, nil
}
// Enact implements CardOption.
func (b *BasicPolicy) Enact(p *Player) (cardsim.Message, error) {
if b.Do == nil {
return nil, ErrUnimplemented
}
if b.CurrentlyEnacted {
p.ActionsRemaining++
if b.NothingChanged == nil {
b.NothingChanged = cardsim.MsgStr("You continue your current approach.")
}
return b.NothingChanged, nil
}
return b.Do(p)
}
// Unenact implements Policy.
func (b *BasicPolicy) Unenact(p *Player) error {
if !b.CurrentlyEnacted {
return ErrPolicyNotEnacted
}
if b.Undo == nil {
return ErrUnimplemented
}
return b.Undo(p)
}
// Enabled implements CardOption.
func (b *BasicPolicy) Enabled(p *Player) bool {
if b.CurrentlyEnacted {
return true
}
if b.CanDo == nil {
panic(ErrUnimplemented)
}
return b.CanDo(b, p)
}
func (b *BasicPolicy) Is(p Policy) bool {
if o, ok := p.(*BasicPolicy); ok {
return o == b
}
return false
}
// TablePolicy is a Policy where all numerical changes are defined by
// adding a constant to some set of fields (defined by `EffectsTable“)
// and subtracting it back out when de-enacting. If the currently
// enacted option is re-enacted, it refunds the player's action point.
type TablePolicy struct {
Desc cardsim.Message
UnenactedDesc cardsim.Message
EnactedDesc cardsim.Message
NothingChanged cardsim.Message
EffectsTable map[FieldLabel]float64
EnactionDesc cardsim.Message
CanDo func(*TablePolicy, *Player) bool
CurrentlyEnacted bool
LastEnactedPolicy Policy
LastEnactedIdx int
}
func YesWeAlsoCan(*TablePolicy, *Player) bool {
return true
}
// LastEnacted notifies t about the last-enacted policy in its group. It updates
// t.currentlyEnacted accordingly.
func (t *TablePolicy) LastEnacted(i int, p Policy) {
t.LastEnactedPolicy = p
t.LastEnactedIdx = i
t.CurrentlyEnacted = t.Is(p)
}
// OptionText implements CardOption.
func (t *TablePolicy) OptionText(*Player) (cardsim.Message, error) {
if t.CurrentlyEnacted {
if t.EnactedDesc == nil {
return nil, ErrUnimplemented
}
return t.EnactedDesc, nil
}
if t.UnenactedDesc == nil {
return nil, ErrUnimplemented
}
return t.UnenactedDesc, nil
}
// Enact implements CardOption.
func (t *TablePolicy) Enact(p *Player) (cardsim.Message, error) {
if t.EffectsTable == nil {
return nil, ErrUnimplemented
}
if t.CurrentlyEnacted {
p.ActionsRemaining++
if t.NothingChanged == nil {
t.NothingChanged = cardsim.MsgStr("You continue your current approach.")
}
return t.NothingChanged, nil
}
var errs cardsim.ErrorCollector
for label, amount := range t.EffectsTable {
errs.Add(p.Stats.Add(label, amount))
}
return t.EnactionDesc, errs.Emit()
}
// Unenact implements Policy.
func (t *TablePolicy) Unenact(p *Player) error {
if !t.CurrentlyEnacted {
return ErrPolicyNotEnacted
}
var errs cardsim.ErrorCollector
for label, amount := range t.EffectsTable {
errs.Add(p.Stats.Add(label, -amount))
}
return errs.Emit()
}
// Enabled implements CardOption.
func (t *TablePolicy) Enabled(p *Player) bool {
if t.CurrentlyEnacted {
return true
}
if t.CanDo == nil {
panic(ErrUnimplemented)
}
return t.CanDo(t, p)
}
func (t *TablePolicy) Is(p Policy) bool {
if o, ok := p.(*TablePolicy); ok {
return o == t
}
return false
}
// A VerbosePolicy is a group of related policies pretending to all be the same
// policy. Which policy is used is determined by what the previous policy for
// the card was (as reported via a call to LastEnacted):
//
// - If no policy has yet been enacted, use FirstTime.
// - If a policy has been enacted, use the Policy at the slot in Variants
// that corresponds to the slot (on the Card) of the currently-enacted policy.
// - If the policy retrieved in this way returns ErrUnimplemented, throw away
// its response and use Default instead. For Enabled, which does not have
// an error component to its return value, look for ErrUnimplemented as the
// argument to a Panic call, instead.
// - If the policy retrieved in this way returns ErrKeepMessage when Enact
// is called, it calls Default for the side effects but ignores its message,
// retaining the message from the original call. This is to avoid having to
// repeat the same Enact function except with different text each time.
// OptionText does this too, even though OptionText doesn't have side effects,
// so the same helper function can create a "constant message" callback
// that works the same for both fields so someone implementing a card won't
// accidentally fail to enact their policy's effects by using the wrong one
// in the wrong slot.
type VerbosePolicy struct {
Default Policy
FirstTime Policy
lastIdx int
lastWasMe bool
lastEnacted Policy
Variants []Policy
}
func (v *VerbosePolicy) LastEnacted(i int, p Policy) {
v.lastIdx = i
v.lastEnacted = p
v.lastWasMe = v.Is(p)
// make sure we can just assume that there is a policy in this slot,
// inserting the default if there is none.
v.fillDefaults()
// Tell the potential candidate policy about this, too. Two special cases:
// * first time -- use first-time policy
// * lastWasMe -- tell the polcy that the last encated policy was itself,
// since it doesn't know it's wrapped in another policy and would not
// recognize itself as v
if i < 0 {
v.FirstTime.LastEnacted(i, p) // p should be nil here...
} else if v.lastWasMe {
v.Variants[i].LastEnacted(i, v.Variants[i])
} else {
v.Variants[i].LastEnacted(i, p)
}
// In case we need it, also prepare the Default for use.
if v.lastWasMe {
v.Default.LastEnacted(i, v.Default)
} else {
v.Default.LastEnacted(i, p)
}
}
func (v *VerbosePolicy) fillDefaults() {
if v.FirstTime == nil {
v.FirstTime = v.Default
}
for len(v.Variants) <= v.lastIdx {
v.Variants = append(v.Variants, v.Default)
}
if v.lastIdx >= 0 && v.Variants[v.lastIdx] == nil {
v.Variants[v.lastIdx] = v.Default
}
}
func (v *VerbosePolicy) OptionText(p *Player) (cardsim.Message, error) {
var msg cardsim.Message
var err error
if v.lastIdx < 0 {
msg, err = v.FirstTime.OptionText(p)
} else {
msg, err = v.Variants[v.lastIdx].OptionText(p)
}
if errors.Is(err, ErrKeepMessage) {
_, err = v.Default.OptionText(p)
} else if errors.Is(err, ErrUnimplemented) {
msg, err = v.Default.OptionText(p)
}
return msg, err
}
func (v *VerbosePolicy) Enact(p *Player) (cardsim.Message, error) {
var msg cardsim.Message
var err error
if v.lastIdx < 0 {
msg, err = v.FirstTime.Enact(p)
} else {
msg, err = v.Variants[v.lastIdx].Enact(p)
}
if errors.Is(err, ErrKeepMessage) {
_, err = v.Default.Enact(p)
} else if errors.Is(err, ErrUnimplemented) {
msg, err = v.Default.Enact(p)
}
return msg, err
}
func (v *VerbosePolicy) Unenact(p *Player) error {
if !v.lastWasMe {
return ErrPolicyNotEnacted
}
var err error
if v.lastIdx < 0 {
err = v.FirstTime.Unenact(p)
} else {
err = v.Variants[v.lastIdx].Unenact(p)
}
if errors.Is(err, ErrUnimplemented) {
err = v.Default.Unenact(p)
}
return err
}
func (v *VerbosePolicy) Is(p Policy) bool {
if o, ok := p.(*VerbosePolicy); ok {
return o == v
}
return false
}
func (v *VerbosePolicy) Enabled(p *Player) (result bool) {
// oops, enablement isn't designed to error out. so we have to use
// panic/recover for this.
defer func() {
if x := recover(); x != nil {
if e, ok := x.(error); ok {
if errors.Is(e, ErrUnimplemented) {
// Recover and use the Default to cover for the missing
// Enabled method.
result = v.Default.Enabled(p)
return
}
}
// Whatever we caught, it's not something we're actually ready to recover from.
panic(x)
}
}()
if v.lastIdx < 0 {
result = v.FirstTime.Enabled(p)
return
}
result = v.Variants[v.lastIdx].Enabled(p)
return
}
// FuncPolicy implements Policy by calling specified functions. If they're
// missing, it returns ErrUnimplemented. It handles Is itself. It also tracks
// LastEnacted data (last index, which policy, is policy self) itself.
type FuncPolicy struct {
OptionTextFunc func(*Player) (cardsim.Message, error)
EnactFunc func(*Player) (cardsim.Message, error)
EnabledFunc func(*Player) bool
UnenactFunc func(*Player) error
// These three fields are assigned by LastEnacted and typically should not
// be set in the initializer.
LastEnactedIdx int
LastEnactedPolicy Policy
WasEnactedLast bool
}
func (f *FuncPolicy) OptionText(p *Player) (cardsim.Message, error) {
if f.OptionTextFunc == nil {
return nil, ErrUnimplemented
}
return f.OptionTextFunc(p)
}
func (f *FuncPolicy) Enact(p *Player) (cardsim.Message, error) {
if f.EnactFunc == nil {
return nil, ErrUnimplemented
}
return f.EnactFunc(p)
}
func (f *FuncPolicy) Enabled(p *Player) bool {
if f.EnabledFunc == nil {
panic(ErrUnimplemented)
}
return f.EnabledFunc(p)
}
func (f *FuncPolicy) Unenact(p *Player) error {
if f.UnenactFunc == nil {
return ErrUnimplemented
}
return f.UnenactFunc(p)
}
func (f *FuncPolicy) LastEnacted(i int, p Policy) {
f.LastEnactedIdx = i
f.LastEnactedPolicy = p
f.WasEnactedLast = f.Is(p)
}
func (f *FuncPolicy) Is(p Policy) bool {
fp, ok := p.(*FuncPolicy)
return ok && (f == fp)
}
// A DisabledPolicy is never enabled.
type DisabledPolicy struct {
Msg cardsim.Message
}
func (d *DisabledPolicy) OptionText(p *Player) (cardsim.Message, error) {
return d.Msg, nil
}
func (d *DisabledPolicy) Enact(*Player) (cardsim.Message, error) {
return nil, ErrOptionNotEnabled
}
func (d *DisabledPolicy) Enabled(*Player) bool {
return false
}
func (d *DisabledPolicy) Unenact(*Player) error {
return ErrPolicyNotEnacted
}
func (d *DisabledPolicy) LastEnacted(int, Policy) {}
func (d *DisabledPolicy) Is(p Policy) bool {
dp, ok := p.(*DisabledPolicy)
return ok && (dp == d)
}
// ShuffleIntoBottomHalf is a common "what to do with the card after?" behavior.
func ShuffleIntoBottomHalf(c Card, p *Player, _ CardOption) error {
p.Deck.InsertRandomBottom(0.5, c)
return nil
}
// OverrideDefaultMsg returns a closure that returns the provided message and
// ErrKeepMessage. This can be used in a FuncPolicy to tell a VerbosePolicy
// to use its Default but keep the message written here, to avoid writing
// repetitive Enact funcs (and, for that matter, OptionText funcs, even though
// the underlying Default call should be unnecessary).
func OverrideMsg(m cardsim.Message) func(*Player) (cardsim.Message, error) {
return func(p *Player) (cardsim.Message, error) {
return m, ErrKeepMessage
}
}