jkomoros / boardgame

An in-progress framework in golang to easily build boardgame Progressive Web Apps
Apache License 2.0
31 stars 4 forks source link

Introduce notion of enums as PropertyType #457

Closed jkomoros closed 7 years ago

jkomoros commented 7 years ago

For example anything in components.go there should be a relatively easy way to:

1) Verify that a given value is in the enum. Get its string value, tell if it's invalid, and tell which one is greater. 2) Have access to those enums clientside in an auto-generated file

Would be useful for for example Valentine, but also a lot of other aspects.

Perhaps it's a new PropertyType of Enum, where a game is configured with a named enum, which is effectively like a deck. Then the enum will have properties to say its string value and verify that it's legal.

In early designs these were called "Colors" I believe.

Goals: 1) Easy to compare to actual constants in Go for type safety, e.g. theColor == ColorRed 2) Can always verify that the property that is an enum is never set to an illegal value 3) Way to convert to a string 4) Can be shipped to client in an auto-generated way and auto-expanded 5) They aren't high overhead memory/CPU wise 6) They don't require a lot of annoyance to use (e.g. val, err := prop.GetConstantEnum("bam"); if val != nil; ...)

Nice to have: 1) You can have them based on an underlying type like type Color int

jkomoros commented 7 years ago

boardgame/enum.go

type Enum struct {
  names map[EnumConstant]string
  low, high int
}

func (e *Enum) Valid(v EnumConstant) bool {
  if v < low || v > high {
    return false
  }
  return true
}

func (e *Enum) String(v EnumConstant) string {
  return e.names[v]
}

//EnumProperty should be used like a Stack: as a property on Game or Player state. You create them in EmptyGameState initialized with the enum name
type EnumProperty struct {
  enumName string
  value EnumConstant
}

func (e *EnumProperty) Valid() bool {
  return e.state.game.getEnum(e.enumName).Valid(e.value)
}

func (e *EnumProperty) String() string {
  //... similar to Valid()
}

func (e *EnumProperty) Value() EnumConstant {
  //... similar to Valid()
}

func (e *EnumProperty) SetValue(v EnumConstant) bool {
   enum := e.state.game.getEnum(e.enumName)

  if e.enumName != v.enum {
    return false
  }

  if !enum.Valid(v) {
    return false
  }

  e.value = v
  return true
}

type EnumConstant struct {
  enum string
  value int
}

//enumConstant can't have a String() because we don't have a canonical reference to the enum, we just know its name

type (e *EnumConstant) Int() int {
  return e.int
}

func (g *GameManager) AddEnum(name string, values ...EnumConstant) {
  //Create a new Enum with the given name. The values will automatically get stringified by taking the name of them, getting rid of the prefix of name, and using those as the string value. 
}

mygame/components.go

const (
  CardBlue = iota
  CardGreen
  CardRed
)

mygame/main.go

//in NewManager...

manager.AddEnum("Card",CardBlue, CardGreen, CardRed)

//...
jkomoros commented 7 years ago

Another approach is to have all enums be unique ints.

The pattern would look like:

const (
  CardUnknown = iota
  CardSpade
  CardHeart
)

const (
  ColorBlue = CardHeart + 1 + iota
  ColorGreen
  ColorRed
)

Also, if you register enums with pointers does that allow us to get the constant name?

jkomoros commented 7 years ago

OK, that above pattern does work, and the fact that what I was calling EnumConstant are actually ints is useful. So going again:

boardgame/enum.go

type enumRecord struct {
  enumName string
  str string
}

type GameManager struct {
  //...
  enums map[int]enumRecord
  //...
}

func (g *GameManager) AddEnum(enumName, values int...) error {
  for _, v := range values {
    if _, ok := g.enums[v]; ok {
     //Already registered
     return errors.New("Already registered")
    }
    //TODO: set default str using reflection or something
    g.enums[v] = enumRecord{enumName, ""}
  }
  return nil
}

func (g *GameManager) EnumMembership(value int) string {
   return g.enums[value].enumName
}

type EnumProperty struct {
  statePtr *state
  enumName string
  value int
}

func (e *EnumProperty) SetValue(v int) error {
  enumName := e.statePtr.game.manager.EnumMembership(v)
  if enumName != e.enumName {
    return errors.New("Set of a value not part of enum")
  }
  e.value = v
}

func (e *EnumProperty) Value() int {
  return e.value
}

func NewEnumProperty(enumName string) *EnumProperty {
  return &EnumProperty{nil, enumName, -1}
}
jkomoros commented 7 years ago

Enums are included in the Chest payload

jkomoros commented 7 years ago
jkomoros commented 7 years ago

The fact that you need a constructor for Enum is a huge pain (and also breaks this), and makes it hard for them to be in places that totally make sense, like Moves.

What if you created a set of constructors at the top-level at the very beginning that were connected to an enummanager? (Does this even make sense?)

type Enum struct {
  Name string
  Manager *EnumManager
}
//Does something like this work? Is it even useful?
var enum = &boardgame.EnumManager{}

var ColorEnum = enum.NewEnum("Color")

const (
  ColorUnknown = iota
  ColorRed
  ColorGreen
  ColorBlue
)

func init() {
  enum.Add("Color", map[int]string{
    ColorUnknown: "Unknown",
    ColorRed: "Red",
    ColorGreen: "Green",
    ColorBlue: "Blue",
  })
}

func (g *gameDelegate) EmptyGameState() {
  return &gameState{
    MyEnum: boardgame.NewEnumValue(ColorEnum)
  }
}

If we were to go this route we'd have to bring our own, pre-finished EnumManager to Chest, and all games of a certain type would share the same one. (That's OK, as long as they're frozen)

What's the set of things that can be done outside a method? Do they have to all be statically computable? That's OK...

jkomoros commented 7 years ago

type EnumManager struct {
    enums map[string]*Enum
       //If you call Finish() and this is not nil, return an error... allowing us to have NewEnum be usable at top-level
        failedEnums []string
}

type Enum struct {
    Name    string
    Manager *EnumManager
}

func NewEnumManager() *EnumManager {
    return &EnumManager{}
}

func (e *EnumManager) NewEnum(name string, values map[int]string) *Enum {
       //Process values
    return &Enum{name, e}
}

//Doesn't need an inflate; always has its enum! ... wait, does it in UnmarshalJSON? ... I think it does, but it didn't when I was doing this earlier just because I hadn't called inflate before it was called...
type EnumValue struct {
  enum *Enum
  val int
}

func (e *Enum) NewValue() *EnumValue {
  return &EnumValue{e, e.DefaultValue()}
}

func (e *EnumValue) SetValue(value int) bool {

}

func (e *EnumValue) String() string {

}

func (e *EnumValue) Lock() {

}

func (e *EnumValue) SetValueFromString(value string) bool {
   v := e.enum.ValueFromString(string)
   return e.SetValue(v)
}
const (
    ColorUnknown = iota
    ColorRed
    ColorGreen
)

var enumManager = NewEnumManager()

//How to handle when this is an error? We can't do any processing at top-level... NewEnum could keep track of that you tried to create Color but it failed, and then have Finish() fail later with that message
var ColorEnum = enumManager.NewEnum("Color", map[int]string{
    ColorUnknown: "Unknown",
    ColorRed:     "Red",
    ColorGreen:   "Green",
})

func (g *gameDelegate) EmptyGameState() {
  return &gameState{
    MyColor: ColorEnum.NewValue(),
  }
}
jkomoros commented 7 years ago

... Wait, why do we insist that enum values not overlap within a set?

the reason is so that we can expand an int from an unknown enum from the serialization into an EnumValue that references the proper Enum... as long as we know the EnumSet in use. (that is, the field doesn't need any decoration; as long as you know it's an enum value and have the enumset in use, it can just be represented as a bare int and we can expand it).

If we just serailize to string for storage and for transmission then we don't need to transmit the enumset in component chest, and we also don't need to be able to do that party trick... which means enums can overlap, which is much less of a pain to use.

How we do or don't support Moves having enum will be the decider here... it also depends on what is idiomatic for ComponentValues to have. If it's just the bare int then it might invalidate all of this because we'll need a client-side map, which means we'll need all of these party tricks. If componentValues do just use the bare int, how are we supposed to know client-side how to expand them?

We could have a enum.Constant which is how you tell us that the thing is an int that indexes into the int set and should be expanded whenever being serialized? If that's the case then any time you have a constant int it should be one of these enum.Constants

So when you use enum.Constant it means, this is not just some random int it's actually a constant representing a value in an enum, but which enum isn't really important. An enum.Value is used when you you want to say "this value can only be set to the constants in this specific enum".

This would then mean that we'd need to support enum.Constants as a PropertyType.

You'd use an enum.Value for things like State to verify we never set to an illegal one; you'd use enum.Constant for things where either it doesn't change or you don't need enum type safety and all you want to do is communicate that this thing is not-just-an-int.

But like we couldn't have MarshalJSON treat enum.Constant specially because it doesn't know where the enumSet is.

jkomoros commented 7 years ago

Is there a way to support named constant sets? We'd have to treat them all as Ints for the purpose of PropertyReaders, and then autoreader would have to cast to the right underlying type in SetInt() for them, which would be a bit of a pain.

That would get you the settable type safety ish, but you still could set with a value that wasn't valid. And it wouldn't give you the stringablity.

You'd have to do something where set.Add() would have to take map[interface{}]string where the kind of the thing must be int. Would we then need to use reflection for anything other than Add()? If so, that's a dealbreaker.

jkomoros commented 7 years ago

How do we make it so we can convert enum.Constant to a string correctly even without knowing which enum it's part of?

We could switch to having enum only ever have the global enum set. ... No, that doesn't work because we have multiple game managers each with a different enum set.

jkomoros commented 7 years ago

Enum.Value --> Enum.Var

We should use Enum.Vars everywhere. They aren't that much overhead and then we can be explicit, and remove the whole no-overlapping int thing.

Enum.MustLockedVar(ColorRed) gives a pre-locked var for that thing or panics. Useful for set up of component values.

... Now we just need to think about moves and how they;ll be sent back over the wire.

jkomoros commented 7 years ago

Moves may have an EnumVar too, just the biggest annoyance is that in initalizer you have to explicitly expand the enum.

Is there a way to have each enum have a different type registered, and then whenever we see that we treat it like an EnumVar? Like, you embed the Enum.Var in your type? That doesn't work naively because even EnumVars ahve to be initialized to the right value.

... But we could so something where in the property reader we read the Name() and SetVar(*inner) on it.

enum/main.go

type Var interface {
  //Typically this is the only one you implement
  Name() string
  //Typically the rest of these are just part of the embedded struct
  Value() int
  SetValue(int) error
  String() string
}

func (e *Set) AddType(myType Var) {
  //Hmm, how do we get the Var to know what its inner Enum is?
}

mygame/components.go

type Color struct{
 *enum.InnerVar
}

type gameState struct {
  TokenColor *Color
}

Hmmm, maybe the way it works is the object that is created is an Enum itself... no, that won't work because you need it to be a single value...

jkomoros commented 7 years ago

Okay, trying again with the thing up there:

enum/main.go

type Constant interface {
  Value() int
  String() string
  Enum() *Enum
  MarshalJSON() ([]byte, error)
  UnmarshalJSON([]byte) error
}

type Var interface {
  Constant
  SetValue(int) error
}

//These are the default implementation of Var that enum.NewVar() returns. We also return it for NewConstant().
type varImpl struct {
  enum *Enum
  value int
}

type AutoVar interface {
  //Typically this is the only one you implement. It is how the PropertyReader knows which enum to fetch
  Name() string
  //This is on the AutoVarImpl struct
  SetVar(inner Var)
  //We also implement Var
  Var
}

//This is what your custom auto var will embedd
type AutoVarImpl struct {
  Var
}

func (v *AutoVarImpl) SetVar(var Var) {
  v.Var = var
}

boardgame/reader.go


func verifyReader(reader PropertyReader) {
  for propName, propType := range reader.Props() {
    switch propType {
      case TypeEnumVar: 
       //Actually it might be nil at this point... :-/
        autoVar, ok := reader.EnumVar(propName).(enum.AutoVar)
        if ok {
          autoVar.SetVar(chest.Enums().Enum(autoVar.Name()).NewVar())
        }
    }
  }
}

mygame/components.go

const (
  ColorRed = iota
  ColorGreen
)

var Enums = enum.NewSet()

var ColorEnum = Enums.MustAdd("Color", map[int]string{
  ColorRed: "Red",
  ColorGreen: "Green",
})

type Color struct {
  //Non-pointer receiver, so the space is allocated with a single new
  enum.AutoVarImpl
}

//This is the only thing my custom type needs to implement; the rest is handled by AutoVarImpl
type (c *Color) Name() string {
  return "Color"
}

type cardValue struct {
  Value enums.Constant
}

func newDeck() *boardgame.Deck {
  deck := boardgame.NewDeck()
  deck.AddComponent(&cardValue{
    Value: ColorEnum.MustNewConstant(ColorRed),
  })
}

mygame/state.go

//autoreader will look through this, see statically that Color implements Var, and have it return for VarProp()
type gameState struct {
  MyColor enum.Var
}

func (g *gameDelegate) EmptyGameState() boardgame.SubState {
  return &gameState{
    MyColor: ColorEnum.NewVar(),
  }
}

mygame/moves.go

type myMove struct {
  //You must use a Non-pointer struct field here otherwise it won't be allocated with New()
  MyColor Color
}

var myMoveConfig = boardgame.MoveConfig{
  MoveConstructor: func() boardgame.Move {
    return new(myMove)
  }
}

The enum.Var vs enum.Constant break down works quite well. What's not clear is AutoVar... in order for the MoveConstructor's New() to also allocate space for the Color, it has to be a non-pointer reference. But doesn't that mean that accessing it in the future will be a huge pain? And if we can't have a one-line MoveConstructor then we might as well not have AutoVar.

jkomoros commented 7 years ago
jkomoros commented 7 years ago

Another option for a one-line move constructor is to have a struct tag on the Move you return. When you install a move we fetch one, do reflection on it ONCE to figure out which Enum name it is and store the propName --> enumName mapping, then every time you create a new one we will use the PropertyReadSetter to instantiate a Var to the right item. ... But that doesn't really work if you need a specific const value. Although a const doesn't make any sense for a move anyway.

verify readers should be run on moves as well if we have Vars on them

Moves should be verified at install that they don't have any illegal prop types (don't we already do this?)

The crazy struct tagging could be expanded to stacks, and then we could have a generalized Expander that takes a constructor and processes them using reflection at start and later knows how to process them without reflection

jkomoros commented 7 years ago

All remaining todos are captured in new issues