10 Commits

Author SHA1 Message Date
22c4718faf Include the Number in the smoke test display. 2023-04-15 17:30:08 -07:00
deb3b1c5a1 Don't charge an action point for debug drawing. 2023-04-15 17:26:02 -07:00
4a91230376 Smoke testing for debug actions.
Just moves one of the existing actions to a debug action slot.
2023-04-15 17:21:28 -07:00
54711b36a8 Display debug enactables.
Includes some refactoring work to pull out common code and express the idea of "wait, which panel, exactly?".
2023-04-15 17:17:51 -07:00
6c3c936dbd Debug Actions: Another set of permanent actions.
These are only reachable in debug mode.
2023-04-15 16:06:55 -07:00
8d9303c8bc stats manual in README 2023-04-04 12:29:36 -07:00
ad9e5764f1 Allow renaming of extracted stats via tag. 2023-04-04 12:14:04 -07:00
99e372a4db Fix it
Pointer vs. value receivers are... interesting.
2023-04-04 11:37:02 -07:00
3e34e25f54 Major stats upgrade.
StatLiteral: just emit a stat in the obvious way. Plus helper functions.

Can also identify stats via struct tags, no more Stored type!

Can also identify stat methods via name (with compatible types).
2023-04-04 11:12:07 -07:00
1464070339 Fix excess divider mess in message display. 2023-04-03 01:45:38 -07:00
10 changed files with 456 additions and 68 deletions

View File

@ -42,7 +42,7 @@ A bucket of game state.
### Stat ### Stat
An arbitrary variable (or function, if it's calculated) tagged with some stuff to make it easier to display to the player. An arbitrary variable (or function, if it's calculated) tagged with some stuff to make it easier to display to the player. Stats can be extracted automatically (see "Stat Extraction" below).
### StatsCollection ### StatsCollection
@ -57,3 +57,26 @@ There are some special errors that Rules can use to "communicate with" the rule
### Messages ### Messages
For now, strings but inconvenient. Intended to provide forwards compatibility when we eventually include some way to format text, where all the stuff written for "it's just a string" would break if not for having this extra type in the way to wrap it where we can stay compatible with "it's just a string" mode. For now, strings but inconvenient. Intended to provide forwards compatibility when we eventually include some way to format text, where all the stuff written for "it's just a string" would break if not for having this extra type in the way to wrap it where we can stay compatible with "it's just a string" mode.
## Stat Extraction
The function `ExtractStats` creates a stats list automatically by searching through a struct's fields and methods. The following things are recognized as stats:
* any method with a name like `StatFoo` or `HiddenStatFoo` (the latter are marked as invisible stats, which show up only in debug mode with the implementation in BasicStatsPane)
* any exported field with a type that is already a `Stat`; the `Stored[T]` and `Hidden[T]` generic types are containers for this
* any exported field tagged with `cardsim:"stat"`
* or `cardsim:"hidden"` for hidden stats. `"hiddenstat"` also works.
* you can use `"round2"` to round to two decimal places -- you can use any integer here, not just 2. works with both `float` types.
* `"hiddenround3"` (or any other number) creates a hidden rounded stat.
* To change the display name of a stat, use a separate tag phrase in addition to the stat tag, `cardsim_name:"name"`.
* For example: `cardsim:"stat" cardsim_name:"Stat Display Name"` creates a visible stat that shows up as "Stat Display Name".
* `cardsim:"hiddenround1" cardsim_name:"Hidden Rounded Stat"` creates an invisible stat that shows up, rounded to one decimal place, as "Hidden Rounded Stat".
Stat extraction can implement most or all of your type's `Stats` method for you:
```
func (e *ExampleType) Stats() []cardsim.Stat {
return cardsim.ExtractStats(e)
}
```
ExtractStats puts methods first (lexicographically), then fields (in the order they appear). You can use `cardsim.SortStats` to instead put visible stats before hidden stats, alphabetized (case-insensitive).

View File

@ -12,6 +12,12 @@ type Message interface {
fmt.Stringer fmt.Stringer
} }
// Titled desccribes any type that returns a Message as a title, given a Player
// (which it may ignore).
type Titled[C StatsCollection] interface {
Title(*Player[C]) Message
}
type stringMessage string type stringMessage string
func (s stringMessage) String() string { func (s stringMessage) String() string {

View File

@ -13,6 +13,7 @@ var (
ErrInvalidChoice = errors.New("invalid choice specified") ErrInvalidChoice = errors.New("invalid choice specified")
ErrNotUrgent = errors.New("action not urgent when urgent card is available") ErrNotUrgent = errors.New("action not urgent when urgent card is available")
ErrNoActions = errors.New("no actions remaining") ErrNoActions = errors.New("no actions remaining")
ErrNotDebugging = errors.New("this is a debug-only feature and you're not in debug mode")
WarningStalemate = errors.New("no actions can be taken") WarningStalemate = errors.New("no actions can be taken")
) )
@ -104,6 +105,10 @@ type Player[C StatsCollection] struct {
// card is in the hand. // card is in the hand.
PermanentActions []Card[C] PermanentActions []Card[C]
// DebugActions are PermanentActions only available when the player is in
// debug mode.
DebugActions []Card[C]
// InfoPanels lists informational views available to the player. The Prompt // InfoPanels lists informational views available to the player. The Prompt
// is the InfoPanel shown before the main action menu. // is the InfoPanel shown before the main action menu.
InfoPanels []InfoPanel[C] InfoPanels []InfoPanel[C]
@ -308,6 +313,38 @@ func (p *Player[C]) HasUrgentCards() bool {
return false return false
} }
// EnactableType is an enumeration representing a category of enactable thing.
// Debug actions, permanent actions, and cards behave equivalently in many ways,
// so EnactableType allows parts of the program to work with any of these and
// represent which one they apply to.
type EnactableType int
const (
// InvalidEnactable is an uninitialized EnactableType value with no meaning.
// Using it is generally an error. If you initialize EnactableType fields
// with this value when your program has not yet calculated what type of
// enactable will be used, CardSimEngine will be able to detect bugs where
// such a calcualation, inadvertently, does not come to any conclusion.
// Unlike NothingEnactable, there are no circumstances where this has a
// specific valid meaning.
InvalidEnactable = EnactableType(iota)
// NothingEnactable specifically represents not enacting anything. In some
// contexts, it's an error to use it; in others, it is a sentinel value
// for "do not enact anything". Unlike InvalidEnactable, this has a specific
// valid meaning, it's just that the meaning is specifically "nothing".
NothingEnactable
// CardEnactable refers to a card in the hand.
CardEnactable
// PermanentActionEnactable refers to an item in the permanent actions list.
PermanentActionEnactable
// DebugActionEnactable refers to an item in the debug actions list.
DebugActionEnactable
)
// EnactCardUnchecked executes a card choice, removes it from the hand, and // EnactCardUnchecked executes a card choice, removes it from the hand, and
// decrements the ActionsRemaining. It does not check for conflicting Urgent // decrements the ActionsRemaining. It does not check for conflicting Urgent
// cards or already being out of actions. If no such card or card choice // cards or already being out of actions. If no such card or card choice
@ -381,10 +418,31 @@ func (p *Player[C]) EnactCard(cardIdx, choiceIdx int) (Message, error) {
// result of enacting the permanent action. If enacting the card causes a // result of enacting the permanent action. If enacting the card causes a
// serious error, the State becomes GameCrashed. // serious error, the State becomes GameCrashed.
func (p *Player[C]) EnactPermanentActionUnchecked(actionIdx, choiceIdx int) (Message, error) { func (p *Player[C]) EnactPermanentActionUnchecked(actionIdx, choiceIdx int) (Message, error) {
if actionIdx < 0 || actionIdx >= len(p.PermanentActions) { return p.enactActionUnchecked(p.PermanentActions, actionIdx, choiceIdx)
return nil, fmt.Errorf("%w: no action #%d when %d permanent actions exist", ErrInvalidCard, actionIdx, len(p.PermanentActions)) }
// EnactDebugActionUnchecked executes a debug action and decrements the
// ActionsRemaining, even though most debug actions will want to refund that
// action point. (Consistency with other actions is important.) It does not
// check for Urgent cards or for already being out of actions. If no such action
// or card option exists, or the option is not enabled, this returns nil and
// ErrInvalidCard or ErrInvalidChoice without changing anything. If the player
// is not in debug mode (DebugLevel >= 1), this returns ErrNotDebugging.
// Otherwise, this returns the result of enacting the debug action. If enacting
// the action causes a serious error, the State becomes GameCrashed.
func (p *Player[C]) EnactDebugActionUnchecked(actionIdx, choiceIdx int) (Message, error) {
if p.DebugLevel < 1 {
return nil, ErrNotDebugging
} }
card := p.PermanentActions[actionIdx] return p.enactActionUnchecked(p.DebugActions, actionIdx, choiceIdx)
}
// enactActionUnchecked implements EnactPermanentActionUnchecked and EnactDebugActionUnchecked.
func (p *Player[C]) enactActionUnchecked(actionSource []Card[C], actionIdx, choiceIdx int) (Message, error) {
if actionIdx < 0 || actionIdx >= len(actionSource) {
return nil, fmt.Errorf("%w: no action #%d when %d actions exist", ErrInvalidCard, actionIdx, len(actionSource))
}
card := actionSource[actionIdx]
var errs ErrorCollector var errs ErrorCollector
options, err := card.Options(p) options, err := card.Options(p)
if IsSeriousError(err) { if IsSeriousError(err) {
@ -393,12 +451,12 @@ func (p *Player[C]) EnactPermanentActionUnchecked(actionIdx, choiceIdx int) (Mes
} }
errs.Add(err) errs.Add(err)
if choiceIdx < 0 || choiceIdx > len(options) { if choiceIdx < 0 || choiceIdx > len(options) {
errs.Add(fmt.Errorf("%w: no option #%d on permanent action #%d with %d options", ErrInvalidChoice, choiceIdx, actionIdx, len(options))) errs.Add(fmt.Errorf("%w: no option #%d on action #%d with %d options", ErrInvalidChoice, choiceIdx, actionIdx, len(options)))
return nil, errs.Emit() return nil, errs.Emit()
} }
chosen := options[choiceIdx] chosen := options[choiceIdx]
if !chosen.Enabled(p) { if !chosen.Enabled(p) {
errs.Add(fmt.Errorf("%w: option #%d on permanent action #%d is not enabled", ErrInvalidChoice, choiceIdx, actionIdx)) errs.Add(fmt.Errorf("%w: option #%d on action #%d is not enabled", ErrInvalidChoice, choiceIdx, actionIdx))
return nil, errs.Emit() return nil, errs.Emit()
} }

View File

@ -4,7 +4,11 @@ import (
"fmt" "fmt"
"reflect" "reflect"
"sort" "sort"
"strconv"
"strings" "strings"
"unicode"
"golang.org/x/exp/constraints"
) )
// A StatsCollection contains stats. // A StatsCollection contains stats.
@ -114,9 +118,30 @@ func (s statFunc[T]) Visible() bool {
return s.visible return s.visible
} }
// ExtractStats pulls all exported Stat fields (not functions) out of a struct. // ExtractStats pulls all exported stats out of a struct. It puts methods before
// If x cannot be resolved to a struct, it panics. It unwraps interfaces and // fields. If the calculated name of a method conflicts with the calculated
// follows pointers to try to find a struct. // name of a stat from a field, the method wins.
//
// A field is a stat if it is of some Stat type or is tagged with `cardsim:"stat"`,
// `cardsim:"hidden"` (invisible stat), `cardsim:"round2"` (or any integer, 2 is
// just an example), or `cardsim:"hiddenround3"`. `hiddenstat`, `statround`, and
// `hiddenstatround` are also accepted, but other orders of these directives
// are not. A "round" stat must be a float type and it will be rounded to
// this number of decimal places.
//
// A method is a Stat if it takes 0 arguments, returns exactly 1 value, and
// starts with Stat or HiddenStat.
//
// The name of these inferred stats is calculated by breaking the name into
// separate words before each capital letter, unless there are consecutive
// capital letters, which it interprets as an initialism (followed by the
// start of another word, if it's not at the end). To insert a space between
// consecutive capital letters, insert an underscore (`_`). This name inference
// trims "Stat" and "HiddenStat" off the front of method names.
//
// To override the name extracted from a field name, add `cardsim_name:"name"`
// to the tag, where the name part is the name you want to use. It will not be
// formatted further - use normal spaces, capitalization, etc.
func ExtractStats(x any) []Stat { func ExtractStats(x any) []Stat {
v := reflect.ValueOf(x) v := reflect.ValueOf(x)
for k := v.Kind(); k == reflect.Pointer || k == reflect.Interface; k = v.Kind() { for k := v.Kind(); k == reflect.Pointer || k == reflect.Interface; k = v.Kind() {
@ -125,19 +150,125 @@ func ExtractStats(x any) []Stat {
if v.Kind() != reflect.Struct { if v.Kind() != reflect.Struct {
panic(fmt.Errorf("%T is not a struct", x)) panic(fmt.Errorf("%T is not a struct", x))
} }
typ := v.Type()
var ret []Stat var ret []Stat
lim := v.NumField()
for i := 0; i < lim; i++ { known := make(map[string]bool)
f := v.Field(i) for _, vv := range []reflect.Value{v, v.Addr()} {
xt := vv.Type()
lim := xt.NumMethod()
for i := 0; i < lim; i++ {
m := xt.Method(i)
if !m.IsExported() {
continue
}
tm := m.Type
if tm.NumIn() != 1 {
// 1 arg -- receiver
continue
}
if tm.NumOut() != 1 {
continue
}
nameParts := explode(m.Name)
if len(nameParts) < 2 {
continue
}
isHidden := false
if nameParts[0] == "Hidden" {
isHidden = true
nameParts = nameParts[1:]
}
if nameParts[0] != "Stat" {
continue
}
n := strings.Join(nameParts[1:], " ")
if n == "" {
continue
}
if known[n] {
continue
}
known[n] = true
val := vv.Method(i).Call([]reflect.Value{})
if len(val) != 1 {
// This shouldn't happen - we already checked Out. Weird.
continue
}
if !val[0].CanInterface() {
continue
}
ret = append(ret, &StatLiteral{
Name: n,
Value: fmt.Sprint(val[0].Interface()),
IsVisible: !isHidden,
})
}
}
fields := reflect.VisibleFields(typ)
for _, sf := range fields {
if !sf.IsExported() {
continue
}
f := v.FieldByIndex(sf.Index)
if !f.CanInterface() { if !f.CanInterface() {
continue continue
} }
x := f.Interface() iface := f.Interface()
if s, ok := x.(Stat); ok { if s, ok := iface.(Stat); ok {
if known[s.StatName()] {
continue
}
known[s.StatName()] = true
ret = append(ret, s) ret = append(ret, s)
continue
} }
if t := sf.Tag.Get("cardsim"); t != "" {
isStat := false
isHidden := false
t = strings.ToLower(t)
t = strings.TrimSpace(t)
if strings.HasPrefix(t, "hidden") {
isStat = true
isHidden = true
t = t[6:]
}
if strings.HasPrefix(t, "stat") {
isStat = true
t = t[4:]
}
var val string
if strings.HasPrefix(t, "round") {
isStat = true
t = t[5:]
n, _ := strconv.Atoi(t)
fs := fmt.Sprintf("%%.%df", n)
val = fmt.Sprintf(fs, iface)
} else if isStat {
val = fmt.Sprint(iface)
} else {
continue // not identifiably a stat
}
nm := strings.Join(explode(sf.Name), " ")
if t := sf.Tag.Get("cardsim_name"); t != "" {
nm = t
}
if known[nm] {
continue
}
known[nm] = true
ret = append(ret, &StatLiteral{
Name: nm,
Value: val,
IsVisible: !isHidden,
})
continue
}
// Else, not a stat.
} }
return ret return ret
} }
@ -173,3 +304,105 @@ func (s statSorter) Less(i, j int) bool {
// Names differ only by capitalization, if that. // Names differ only by capitalization, if that.
return lhs.StatName() < rhs.StatName() return lhs.StatName() < rhs.StatName()
} }
// StatLiteral stores a ready-to-emit stat value.
type StatLiteral struct {
Name string
Value string
IsVisible bool
}
func (s *StatLiteral) StatName() string {
return s.Name
}
func (s *StatLiteral) String() string {
return s.Value
}
func (s *StatLiteral) Visible() bool {
return s.IsVisible
}
func EmitStat(name string, v any) *StatLiteral {
return &StatLiteral{
Name: name,
Value: fmt.Sprint(v),
IsVisible: true,
}
}
func EmitHiddenStat(name string, v any) *StatLiteral {
return &StatLiteral{
Name: name,
Value: fmt.Sprint(v),
IsVisible: false,
}
}
func Statf(name string, f string, args ...any) *StatLiteral {
return &StatLiteral{
Name: name,
Value: fmt.Sprintf(f, args...),
IsVisible: true,
}
}
func HiddentStatf(name string, f string, args ...any) *StatLiteral {
return &StatLiteral{
Name: name,
Value: fmt.Sprintf(f, args...),
IsVisible: false,
}
}
func RoundStat[N constraints.Float](name string, val N, decimals int) *StatLiteral {
f := fmt.Sprintf("%%.%df", decimals)
return &StatLiteral{
Name: name,
Value: fmt.Sprintf(f, val),
IsVisible: true,
}
}
func RoundHiddenStat[N constraints.Float](name string, val N, decimals int) *StatLiteral {
r := RoundStat(name, val, decimals)
r.IsVisible = false
return r
}
// explode turns CamelCase into multiple strings. It recognizes initialisms. To
// split consecutive capital letters into separate words instead of recognizing
// them as an initialism, insert underscores.
func explode(s string) []string {
var parts []string
started := 0
initialism := false
for i, r := range s {
if unicode.IsUpper(r) {
if initialism || (started == i) {
continue
}
if started == i-1 {
initialism = true
continue
}
parts = append(parts, s[started:i])
started = i
continue
}
if r == '_' {
parts = append(parts, s[started:i])
initialism = false
started = i + 1
continue
}
if initialism {
parts = append(parts, s[started:i-1])
initialism = false
started = i - 1
}
}
parts = append(parts, s[started:])
return parts
}

View File

@ -17,7 +17,7 @@ func RunSimpleTerminalUI[C StatsCollection](p *Player[C]) error {
for { for {
for p.CanAct() { for p.CanAct() {
isCard, cardIdx, choiceIdx, err := pickNextAction(p) actionType, cardIdx, choiceIdx, err := pickNextAction(p)
p.ReportError(err) p.ReportError(err)
if IsSeriousError(err) { if IsSeriousError(err) {
if p.DebugLevel < 1 { if p.DebugLevel < 1 {
@ -26,10 +26,18 @@ func RunSimpleTerminalUI[C StatsCollection](p *Player[C]) error {
continue continue
} }
var msg Message var msg Message
if isCard { switch actionType {
case CardEnactable:
msg, err = p.EnactCard(cardIdx, choiceIdx) msg, err = p.EnactCard(cardIdx, choiceIdx)
} else { case DebugActionEnactable:
msg, err = p.EnactDebugActionUnchecked(cardIdx, choiceIdx)
case PermanentActionEnactable:
msg, err = p.EnactPermanentAction(cardIdx, choiceIdx) msg, err = p.EnactPermanentAction(cardIdx, choiceIdx)
case NothingEnactable:
continue
default:
msg = nil
err = fmt.Errorf("invalid enaction type in action loop: %d", actionType)
} }
p.ReportError(err) p.ReportError(err)
if IsSeriousError(err) { if IsSeriousError(err) {
@ -77,7 +85,7 @@ func wait() {
fmt.Scanln() fmt.Scanln()
} }
func displayMainMenu[C StatsCollection](p *Player[C]) (actionsOffset, handOffset, max int) { func displayMainMenu[C StatsCollection](p *Player[C]) (debugOffset, actionsOffset, handOffset, max int) {
cls() cls()
needsDivider := displayMessageSection(p) needsDivider := displayMessageSection(p)
if needsDivider { if needsDivider {
@ -85,10 +93,14 @@ func displayMainMenu[C StatsCollection](p *Player[C]) (actionsOffset, handOffset
} }
displayOnePanel(p, p.Prompt) displayOnePanel(p, p.Prompt)
divider() divider()
actionsOffset = displayStatsMenu(p) debugOffset = displayStatsMenu(p)
if actionsOffset > 0 { if debugOffset > 0 {
divider() divider()
} }
actionsOffset = displayDebugActionsMenu(p, debugOffset)
if actionsOffset > debugOffset {
fmt.Println()
}
handOffset = displayPermanentActionsMenu(p, actionsOffset) handOffset = displayPermanentActionsMenu(p, actionsOffset)
if handOffset > actionsOffset { if handOffset > actionsOffset {
fmt.Println() fmt.Println()
@ -97,9 +109,9 @@ func displayMainMenu[C StatsCollection](p *Player[C]) (actionsOffset, handOffset
return // uses named return values return // uses named return values
} }
func pickNextAction[C StatsCollection](p *Player[C]) (isCard bool, cardIdx int, choiceIdx int, err error) { func pickNextAction[C StatsCollection](p *Player[C]) (actionType EnactableType, cardIdx int, choiceIdx int, err error) {
for { for {
actionsOffset, handOffset, max := displayMainMenu(p) debugOffset, actionsOffset, handOffset, max := displayMainMenu(p)
divider() divider()
fmt.Printf("%d actions remaining.\n", p.ActionsRemaining) fmt.Printf("%d actions remaining.\n", p.ActionsRemaining)
@ -117,9 +129,8 @@ func pickNextAction[C StatsCollection](p *Player[C]) (isCard bool, cardIdx int,
case "s", "stat", "stats", "i", "info", "p", "panel", "panels", "infopanel", "infopanels": case "s", "stat", "stats", "i", "info", "p", "panel", "panels", "infopanel", "infopanels":
statsMode(p) statsMode(p)
case "a", "act", "actions": case "a", "act", "actions":
var committed bool actionType, cardIdx, choiceIdx, err = actionsMode(p, true)
isCard, cardIdx, choiceIdx, committed, err = actionsMode(p, true) if actionType != NothingEnactable {
if committed {
return return
} }
case "q", "quit", "exit": case "q", "quit", "exit":
@ -132,21 +143,27 @@ func pickNextAction[C StatsCollection](p *Player[C]) (isCard bool, cardIdx int,
} else if i > max { } else if i > max {
fmt.Println("That's not on this menu. If the menu is too big to read, choose a detail view.") fmt.Println("That's not on this menu. If the menu is too big to read, choose a detail view.")
wait() wait()
} else if i <= actionsOffset { } else if i <= debugOffset {
cls() cls()
displayOnePanel(p, p.InfoPanels[i-1]) displayOnePanel(p, p.InfoPanels[i-1])
wait() wait()
} else if i <= actionsOffset {
i = i - debugOffset - 1
option, promptErr := promptCard(p, p.DebugActions[i])
if option >= 0 || IsSeriousError(promptErr) {
return DebugActionEnactable, i, option, promptErr
}
} else if i <= handOffset { } else if i <= handOffset {
i = i - actionsOffset - 1 i = i - actionsOffset - 1
option, promptErr := promptCard(p, p.PermanentActions[i]) option, promptErr := promptCard(p, p.PermanentActions[i])
if option >= 0 || IsSeriousError(promptErr) { if option >= 0 || IsSeriousError(promptErr) {
return false, i, option, promptErr return PermanentActionEnactable, i, option, promptErr
} }
} else { } else {
i = i - handOffset - 1 i = i - handOffset - 1
option, promptErr := promptCard(p, p.Hand[i]) option, promptErr := promptCard(p, p.Hand[i])
if option >= 0 || IsSeriousError(promptErr) { if option >= 0 || IsSeriousError(promptErr) {
return true, i, option, nil return CardEnactable, i, option, nil
} }
} }
} }
@ -206,14 +223,11 @@ func displayMessageSection[C StatsCollection](p *Player[C]) bool {
if len(p.TemporaryMessages) == 0 { if len(p.TemporaryMessages) == 0 {
return false return false
} }
hasPrevious := false
for _, m := range p.TemporaryMessages { for _, m := range p.TemporaryMessages {
if m != nil { if m != nil {
if hasPrevious {
lightDivider()
}
display(m) display(m)
hasPrevious = true } else {
fmt.Println()
} }
} }
return true return true
@ -225,9 +239,7 @@ func displayStatsMenu[C StatsCollection](p *Player[C]) int {
} }
fmt.Println("Info Panels") fmt.Println("Info Panels")
fmt.Println("-----------") fmt.Println("-----------")
for i, s := range p.InfoPanels { displayNumberedTitles(p, p.InfoPanels, 0)
fmt.Printf("[%2d]: %s\n", i+1, s.Title(p).String())
}
return len(p.InfoPanels) return len(p.InfoPanels)
} }
@ -237,22 +249,34 @@ func displayPermanentActionsMenu[C StatsCollection](p *Player[C], offset int) in
} }
fmt.Println("Always Available") fmt.Println("Always Available")
fmt.Println("----------------") fmt.Println("----------------")
for i, s := range p.PermanentActions { displayNumberedTitles(p, p.PermanentActions, offset)
fmt.Printf("[%2d]: %s\n", i+offset+1, s.Title(p))
}
return offset + len(p.PermanentActions) return offset + len(p.PermanentActions)
} }
func displayDebugActionsMenu[C StatsCollection](p *Player[C], offset int) int {
if p.DebugLevel < 1 || len(p.DebugActions) == 0 {
return offset
}
fmt.Println("Debug Mode")
fmt.Println("----------")
displayNumberedTitles(p, p.DebugActions, offset)
return offset + len(p.DebugActions)
}
func displayHandMenu[C StatsCollection](p *Player[C], offset int) int { func displayHandMenu[C StatsCollection](p *Player[C], offset int) int {
if len(p.Hand) == 0 { if len(p.Hand) == 0 {
return offset return offset
} }
fmt.Println("Hand") fmt.Println("Hand")
fmt.Println("----") fmt.Println("----")
for i, s := range p.Hand { displayNumberedTitles(p, p.Hand, offset)
return offset + len(p.Hand)
}
func displayNumberedTitles[C StatsCollection, T Titled[C]](p *Player[C], cards []T, offset int) {
for i, s := range cards {
fmt.Printf("[%2d]: %s\n", i+offset+1, s.Title(p)) fmt.Printf("[%2d]: %s\n", i+offset+1, s.Title(p))
} }
return offset + len(p.Hand)
} }
// promptCard asks the player to take an action on a card. Returns the option // promptCard asks the player to take an action on a card. Returns the option
@ -388,12 +412,16 @@ func statsMode[C StatsCollection](p *Player[C]) error {
} }
} }
func actionsMode[C StatsCollection](p *Player[C], canAct bool) (isCard bool, cardIdx, choiceIdx int, committed bool, err error) { func actionsMode[C StatsCollection](p *Player[C], canAct bool) (actionType EnactableType, cardIdx, choiceIdx int, err error) {
var errs ErrorCollector var errs ErrorCollector
for { for {
cls() cls()
pOff := displayPermanentActionsMenu(p, 0) dOff := displayDebugActionsMenu(p, 0)
if pOff > 0 { if dOff > 0 {
fmt.Println()
}
pOff := displayPermanentActionsMenu(p, dOff)
if pOff > dOff {
fmt.Println() fmt.Println()
} }
max := displayHandMenu(p, pOff) max := displayHandMenu(p, pOff)
@ -403,7 +431,7 @@ func actionsMode[C StatsCollection](p *Player[C], canAct bool) (isCard bool, car
fmt.Println("That's a problem. The game is stuck.") fmt.Println("That's a problem. The game is stuck.")
confirmQuit() confirmQuit()
errs.Add(WarningStalemate) errs.Add(WarningStalemate)
return false, -1, -1, true, errs.Emit() return NothingEnactable, -1, -1, errs.Emit()
} }
fmt.Println() fmt.Println()
@ -415,7 +443,7 @@ func actionsMode[C StatsCollection](p *Player[C], canAct bool) (isCard bool, car
input := getResponse() input := getResponse()
switch input { switch input {
case "b", "back": case "b", "back":
return false, -1, -1, false, errs.Emit() return NothingEnactable, -1, -1, errs.Emit()
case "q", "quit": case "q", "quit":
confirmQuit() confirmQuit()
default: default:
@ -426,19 +454,35 @@ func actionsMode[C StatsCollection](p *Player[C], canAct bool) (isCard bool, car
} else if v < 1 || v > max { } else if v < 1 || v > max {
fmt.Println("That's not a card or action.") fmt.Println("That's not a card or action.")
wait() wait()
} else if v <= pOff { } else if v <= dOff {
v-- v--
if canAct {
optIdx, err := promptCard(p, p.DebugActions[v])
errs.Add(err)
if optIdx >= 0 || IsSeriousError(err) {
return DebugActionEnactable, v, optIdx, errs.Emit()
}
} else {
_, _, err := displayCard(p, p.DebugActions[v], false)
errs.Add(err)
if IsSeriousError(err) {
return DebugActionEnactable, -1, -1, errs.Emit()
}
wait()
}
} else if v <= pOff {
v = v - dOff - 1
if canAct { if canAct {
optIdx, err := promptCard(p, p.PermanentActions[v]) optIdx, err := promptCard(p, p.PermanentActions[v])
errs.Add(err) errs.Add(err)
if optIdx >= 0 || IsSeriousError(err) { if optIdx >= 0 || IsSeriousError(err) {
return false, v, optIdx, true, errs.Emit() return PermanentActionEnactable, v, optIdx, errs.Emit()
} }
} else { } else {
_, _, err := displayCard(p, p.PermanentActions[v], false) _, _, err := displayCard(p, p.PermanentActions[v], false)
errs.Add(err) errs.Add(err)
if IsSeriousError(err) { if IsSeriousError(err) {
return false, -1, -1, true, errs.Emit() return PermanentActionEnactable, -1, -1, errs.Emit()
} }
wait() wait()
} }
@ -448,13 +492,13 @@ func actionsMode[C StatsCollection](p *Player[C], canAct bool) (isCard bool, car
optIdx, err := promptCard(p, p.Hand[v]) optIdx, err := promptCard(p, p.Hand[v])
errs.Add(err) errs.Add(err)
if optIdx >= 0 || IsSeriousError(err) { if optIdx >= 0 || IsSeriousError(err) {
return true, v, optIdx, false, errs.Emit() return CardEnactable, v, optIdx, errs.Emit()
} }
} else { } else {
_, _, err := displayCard(p, p.Hand[v], false) _, _, err := displayCard(p, p.Hand[v], false)
errs.Add(err) errs.Add(err)
if IsSeriousError(err) { if IsSeriousError(err) {
return false, -1, -1, false, errs.Emit() return CardEnactable, -1, -1, errs.Emit()
} }
wait() wait()
} }
@ -467,7 +511,7 @@ func actionsMode[C StatsCollection](p *Player[C], canAct bool) (isCard bool, car
func review[C StatsCollection](p *Player[C]) error { func review[C StatsCollection](p *Player[C]) error {
var errs ErrorCollector var errs ErrorCollector
for { for {
actionsOffset, handOffset, max := displayMainMenu(p) debugOffset, actionsOffset, handOffset, max := displayMainMenu(p)
divider() divider()
fmt.Println("No actions remaining.") fmt.Println("No actions remaining.")
fmt.Printf("(C)ontinue, review just (M)essages, (I)nfo Panels, (A)ctions, or an item (1-%d), or (Q)uit? > ", max) fmt.Printf("(C)ontinue, review just (M)essages, (I)nfo Panels, (A)ctions, or an item (1-%d), or (Q)uit? > ", max)
@ -488,7 +532,7 @@ func review[C StatsCollection](p *Player[C]) error {
return errs.Emit() return errs.Emit()
} }
case "a", "act", "actions": case "a", "act", "actions":
_, _, _, _, err := actionsMode(p, false) _, _, _, err := actionsMode(p, false)
errs.Add(err) errs.Add(err)
if IsSeriousError(err) { if IsSeriousError(err) {
return errs.Emit() return errs.Emit()
@ -501,14 +545,18 @@ func review[C StatsCollection](p *Player[C]) error {
i, err := strconv.Atoi(input) i, err := strconv.Atoi(input)
if err != nil { if err != nil {
fmt.Println("Sorry, I don't understand.") fmt.Println("Sorry, I don't understand.")
wait()
} else if i <= 0 || i > max { } else if i <= 0 || i > max {
fmt.Println("That's not on this menu. If the menu is too big to read, choose a detail view.") fmt.Println("That's not on this menu. If the menu is too big to read, choose a detail view.")
wait() } else if i <= debugOffset {
} else if i <= actionsOffset {
cls() cls()
displayOnePanel(p, p.InfoPanels[i-1]) displayOnePanel(p, p.InfoPanels[i-1])
wait() } else if i <= actionsOffset {
i = i - debugOffset - 1
_, _, err := displayCard(p, p.DebugActions[i], false)
errs.Add(err)
if IsSeriousError(err) {
return errs.Emit()
}
} else if i <= handOffset { } else if i <= handOffset {
i = i - actionsOffset - 1 i = i - actionsOffset - 1
_, _, err := displayCard(p, p.PermanentActions[i], false) _, _, err := displayCard(p, p.PermanentActions[i], false)
@ -516,7 +564,6 @@ func review[C StatsCollection](p *Player[C]) error {
if IsSeriousError(err) { if IsSeriousError(err) {
return errs.Emit() return errs.Emit()
} }
wait()
} else { } else {
i = i - handOffset - 1 i = i - handOffset - 1
_, _, err := displayCard(p, p.Hand[i], false) _, _, err := displayCard(p, p.Hand[i], false)
@ -524,8 +571,8 @@ func review[C StatsCollection](p *Player[C]) error {
if IsSeriousError(err) { if IsSeriousError(err) {
return errs.Emit() return errs.Emit()
} }
wait()
} }
wait()
} }
} }
} }

1
go.mod
View File

@ -7,4 +7,5 @@ require github.com/kr/pretty v0.3.1
require ( require (
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230321023759-10a507213a29
) )

2
go.sum
View File

@ -6,3 +6,5 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug=
golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=

View File

@ -85,7 +85,7 @@ func (i inverseDivision) OptionText(p *player) (cardsim.Message, error) {
func (i inverseDivision) Enact(p *player) (cardsim.Message, error) { func (i inverseDivision) Enact(p *player) (cardsim.Message, error) {
if p.Stats.Number.Value == 0 { if p.Stats.Number.Value == 0 {
return nil, errors.New("you can't divide by zero!") return nil, errors.New("you can't divide by zero")
} }
p.Stats.Number.Value = int(i) / p.Stats.Number.Value p.Stats.Number.Value = int(i) / p.Stats.Number.Value
return cardsim.MsgStr("Inverse divided."), nil return cardsim.MsgStr("Inverse divided."), nil
@ -119,8 +119,8 @@ func initDeck(d *cardsim.Deck[*SmokeTestCollection]) {
func installPermanentActions(pa *[]card) { func installPermanentActions(pa *[]card) {
*pa = []card{ *pa = []card{
&cardsim.BasicCard[*SmokeTestCollection]{ &cardsim.BasicCard[*SmokeTestCollection]{
CardTitle: cardsim.MsgStr("Reset to 0"), CardTitle: cardsim.MsgStr("Reset Number"),
CardText: cardsim.MsgStr("Resets Number to 0."), CardText: cardsim.MsgStr("Resets Number to a fixed value."),
CardOptions: []cardOption{ CardOptions: []cardOption{
&cardsim.BasicOption[*SmokeTestCollection]{ &cardsim.BasicOption[*SmokeTestCollection]{
Text: cardsim.MsgStr("Reset to 0."), Text: cardsim.MsgStr("Reset to 0."),
@ -130,12 +130,6 @@ func installPermanentActions(pa *[]card) {
}, },
Output: cardsim.MsgStr("Done."), Output: cardsim.MsgStr("Done."),
}, },
},
},
&cardsim.BasicCard[*SmokeTestCollection]{
CardTitle: cardsim.MsgStr("Reset to 1000000"),
CardText: cardsim.MsgStr("Resets Number to one million."),
CardOptions: []cardOption{
&cardsim.BasicOption[*SmokeTestCollection]{ &cardsim.BasicOption[*SmokeTestCollection]{
Text: cardsim.MsgStr("Reset to 1,000,000"), Text: cardsim.MsgStr("Reset to 1,000,000"),
Effect: func(p *player) error { Effect: func(p *player) error {
@ -146,6 +140,11 @@ func installPermanentActions(pa *[]card) {
}, },
}, },
}, },
}
}
func installDebugActions(pa *[]card) {
*pa = []card{
&cardsim.BasicCard[*SmokeTestCollection]{ &cardsim.BasicCard[*SmokeTestCollection]{
CardTitle: cardsim.MsgStr("Draw a card"), CardTitle: cardsim.MsgStr("Draw a card"),
CardText: cardsim.MsgStr("Draw an extra card."), CardText: cardsim.MsgStr("Draw an extra card."),
@ -158,6 +157,10 @@ func installPermanentActions(pa *[]card) {
Output: cardsim.MsgStr("Drawn. Probably."), Output: cardsim.MsgStr("Drawn. Probably."),
}, },
}, },
AfterOption: func(c card, p *player, option cardOption) error {
p.ActionsRemaining++
return nil
},
}, },
} }
} }

View File

@ -11,6 +11,11 @@ type SmokeTestCollection struct {
Turns cardsim.Invisible[int] Turns cardsim.Invisible[int]
Flavor cardsim.Stored[string] Flavor cardsim.Stored[string]
Things int `cardsim:"stat" cardsim_name:"A Renamed Thing"`
MoreThings int `cardsim:"hidden"`
FloatyThings float64 `cardsim:"round1"`
Label string `cardsim:"stat"`
} }
func (c *SmokeTestCollection) Average() float64 { func (c *SmokeTestCollection) Average() float64 {
@ -23,3 +28,7 @@ func (c *SmokeTestCollection) Stats() []cardsim.Stat {
cardsim.SortStats(stats) cardsim.SortStats(stats)
return stats return stats
} }
func (c *SmokeTestCollection) StatTotalThings() float64 {
return float64(c.Things+c.MoreThings) + c.FloatyThings
}

View File

@ -28,6 +28,10 @@ func main() {
Name: "Flavor", Name: "Flavor",
Value: "Lemon", Value: "Lemon",
}, },
Things: 5,
MoreThings: 9,
FloatyThings: 123.456,
Label: "whee",
}, },
) )
p.Name = "Dave" p.Name = "Dave"
@ -36,6 +40,7 @@ func main() {
installRules(p.Rules) installRules(p.Rules)
initDeck(p.Deck) initDeck(p.Deck)
installPermanentActions(&p.PermanentActions) installPermanentActions(&p.PermanentActions)
installDebugActions(&p.DebugActions)
p.InfoPanels = []cardsim.InfoPanel[*SmokeTestCollection]{ p.InfoPanels = []cardsim.InfoPanel[*SmokeTestCollection]{
&cardsim.BasicStatsPanel[*SmokeTestCollection]{ &cardsim.BasicStatsPanel[*SmokeTestCollection]{
Name: cardsim.MsgStr("Stats"), Name: cardsim.MsgStr("Stats"),
@ -65,6 +70,7 @@ func (prompt) Info(p *cardsim.Player[*SmokeTestCollection]) ([]cardsim.Message,
return []cardsim.Message{ return []cardsim.Message{
cardsim.MsgStr("Here, have some stuff."), cardsim.MsgStr("Here, have some stuff."),
cardsim.Msgf("It's turn %d according to the player and turn %d according to me.", p.TurnNumber, p.Stats.Turns.Value), cardsim.Msgf("It's turn %d according to the player and turn %d according to me.", p.TurnNumber, p.Stats.Turns.Value),
cardsim.Msgf("The current Number is %d. It tastes like %s.", p.Stats.Number.Value, p.Stats.Flavor.Value),
}, nil }, nil
} }