48 Commits

Author SHA1 Message Date
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
066ec431ff Clan up go.mod. 2023-04-02 22:30:21 -07:00
3455579be6 Fix module spec. 2023-04-02 22:25:12 -07:00
5a7cb58707 Add Godoc badge 2023-04-02 22:14:11 -07:00
74a2493ef4 Clear screen before displaying panel in stats mode. 2023-04-02 19:37:55 -07:00
2c2e68ff93 Fix shadowing bug in rules engine. 2023-04-02 19:36:28 -07:00
d13e04e2f4 Debug utility function and comments 2023-04-02 19:35:07 -07:00
57348f7ebf Add a bunch of logging. 2023-04-02 19:33:44 -07:00
9796c2e970 First round of substantial bug fixes 2023-04-02 19:25:53 -07:00
0f21020647 More fencepost errors! 2023-04-02 19:08:26 -07:00
e96d81a7b4 Fencepost error!
Also the title is visible.
2023-04-02 19:07:05 -07:00
3a7bf9c2fb Don't adjust twice. 2023-04-02 19:05:15 -07:00
00ea284cbc Nil is not an error, much less a serious one. 2023-04-02 19:03:39 -07:00
159f6b6b5f Fix type. 2023-04-02 19:02:43 -07:00
2480a1631b Implement a very crude "game" as a test. Also updates Player. 2023-04-02 19:01:40 -07:00
2875dc5af8 Implement review mode.
This finishes the UI.
2023-04-02 13:58:56 -07:00
74ca51b21d actions mode, prompt cleanup
Fixes some off-by-one errors. A 1-indexed UI in a 0-indexed language is always going to be prone to those and I will no doubt find more when I have enough of a test program to experiment with...
2023-04-02 13:19:26 -07:00
5a2158f525 Implement Stats Mode.
Also rewords some prompts. Might as well be thorough in accepting reasonable inputs.
2023-04-02 12:54:52 -07:00
25a9eed3f0 Prompt for player choices on cards.
Also handles errors in the display/prompt logic somewhat better.
2023-04-02 12:44:29 -07:00
592c877852 More display components. 2023-04-02 00:43:17 -07:00
e1eac9de0f displayStatsMenu 2023-04-02 00:36:12 -07:00
7371cddab3 Add more dividers 2023-04-02 00:28:34 -07:00
aecd8683b2 Restructure loop, basic display functions 2023-04-02 00:26:34 -07:00
a62de999ea Panel display, MultiMessage. 2023-04-02 00:20:37 -07:00
3eb087201f more terminal utility functions 2023-04-02 00:09:17 -07:00
c73545fd07 Implement screen clear. 2023-04-02 00:02:25 -07:00
34e1f3166f Main display mode skeleton 2023-04-01 23:59:53 -07:00
af9d9a6579 Skeleton of prototypical UI. 2023-04-01 23:22:50 -07:00
9e659ecf41 InitPlayer
InitPlayer creates very not-ready Player with its basic data structures initialized to an empty, ready-for-data state.
2023-04-01 22:19:42 -07:00
09fdf19948 CardOption.Enable
Make it possible for a card to display an option but not actually allow it to be selected. It's up to the UI layer to decide how to display options that are not enabled. The option text should probably contiain a note on why the option cannot be selected...
2023-04-01 22:09:05 -07:00
45e1eeebf7 Play cards. 2023-04-01 21:18:48 -07:00
576a2dd69e Implement drawing and running a turn. 2023-04-01 20:52:46 -07:00
a6b2c92f86 Comment and reorganize Player. 2023-04-01 20:04:20 -07:00
75de281cee Refactor Strip (already).
Stripping cards from the Hand will also be useful, so I pulled the logic of Strip out into arrayutil (more efficiently, too) and rewrote deck.Strip to use it.
2023-04-01 19:40:28 -07:00
222d4375ee deck.Strip -- remove cards
Given a function that identifies cards to remove, remove them from the deck. This can be used to find and strip out cards that are only conditionally appropriate (and aren't appropriate anymore).
2023-04-01 19:31:50 -07:00
b806264154 Fraction-specified shufflers.
Convenience methods for "shuffle the bottom third of the deck" and stuff like that.
2023-04-01 19:23:14 -07:00
b8c0e5603a Add a general shuffler and deck shuffling. 2023-04-01 19:13:42 -07:00
20561c574c sliceutil: helpers for mid-slice insert/delete.
Manipulating the hand, deck, etc. is going to use these operations a lot.
2023-04-01 18:49:06 -07:00
99e9e35b1d Card.Drawn, to give cards a chance to not show up
A card can be shuffled into the deck because of a certain condition, and then that condition could cease to apply. If the card should not be presented to the player, it gets one last chance to hide.

There is currently no direct mechanism for making a card _already in the hand_ disappear if it is not relevant, although there are various ways to implement a Rule to do this.
2023-04-01 18:05:57 -07:00
fd35090b34 Generics tutorial on Rule 2023-04-01 17:56:31 -07:00
d569e77fc9 Fix InsertRandomRange warnings.
I was using the wrong format string.
2023-04-01 14:39:56 -07:00
bcfd42970b BasicStatsPanel
Implement a stats panel with a name, intro text, and a rule for how to pull stats out of a collection.
2023-04-01 14:38:45 -07:00
b324a39918 Explain generic types and add DebugLevel to Player
This simulation engine is intended for people who are interested in game design, not computer programming -- the engine wants to do all the engine stuff so the simulation can be implemented with less familiarity with the language. Generics, however, are not widely regarded as a "new programmer" thing -- even though they're surprisingly familiar, in the end  (slice-of-T, map-from-K-to-V). So a long comment explaining a bit about what's going on seems warranted.
2023-04-01 13:32:25 -07:00
7f8dcd63d6 Introduce InfoPanel.
InfoPanels are information displays that do not cost actions. One of them (the Prompt) is shown at the main menu; others can be made available as options in the menu, ether on an ongoing basis or for the current/next turn only.
2023-04-01 12:30:39 -07:00
fb5735d5b9 Multiple actions per turn
Allow a pattern of multiple actions per turn. Card options can "refund" an action to the player if they should be free, or zero out the remaining actions if they should force the turn to go directly to the simulation step.
2023-04-01 12:21:42 -07:00
cef0718cba Cards can be urgent 2023-04-01 12:02:14 -07:00
18 changed files with 2147 additions and 64 deletions

2
.gitignore vendored
View File

@ -21,3 +21,5 @@
# Go workspace file # Go workspace file
go.work go.work
# Visual studio Code exclusions
.vscode/settings.json

View File

@ -2,6 +2,8 @@
Basic engine for NationStates-like "make decisions on issues" simulation games. Very incomplete. Basic engine for NationStates-like "make decisions on issues" simulation games. Very incomplete.
[![Go Reference](https://pkg.go.dev/badge/git.chromaticdragon.app/kistaro/CardSimEngine.svg)](https://pkg.go.dev/git.chromaticdragon.app/kistaro/CardSimEngine)
## General turn model ## General turn model
1. Player has a hand of cards. Each card has one or more actions available. 1. Player has a hand of cards. Each card has one or more actions available.

View File

@ -5,9 +5,22 @@ package cardsim
type Card[C StatsCollection] interface { type Card[C StatsCollection] interface {
// Title is the short name of the card displayed in the hand // Title is the short name of the card displayed in the hand
// and at the top of the card output. It receives the current // and at the top of the card output. It receives the current
// player as an argument. If it returns an error that is not // player as an argument.
// a warning, the game crashes. Title(p *Player[C]) Message
Title(p *Player[C]) (Message, error)
// Urgent reports whether the card is considered urgent. If
// the player has any urgent cards in hand, they cannot choose to act
// on a non-urgent card.
Urgent(p *Player[C]) bool
// Drawn is invoked after a card is drawn, before presenting it to the
// player. If Drawn returns `false`, the card is discarded without being
// put into the hand or shown to the player and a replacement is drawn
// instead. To put a card back on the bottom of the deck (or similar)
// use p.Deck.Insert (or a related function) to put it back explicitly
// in the right position. Do not put it right back on top of the deck or
// you'll create an infinite loop.
Drawn(p *Player[C]) bool
// EventText returns the text to display on the card. If it returns an // EventText returns the text to display on the card. If it returns an
// error that is not a warning, the game crashes. // error that is not a warning, the game crashes.
@ -36,38 +49,60 @@ type CardOption[C StatsCollection] interface {
// a warning, the game crashes. // a warning, the game crashes.
// //
// After an option is enacted, the card is deleted. If a card should be // After an option is enacted, the card is deleted. If a card should be
// repeatable, Enact must return it to the deck (on every option). // repeatable, Enact must return it to the deck (on every option) or
// the card needs to reinsert itself with its Then function.
Enact(p *Player[C]) (Message, error) Enact(p *Player[C]) (Message, error)
// Enabled returns whether this option can curently be enacted.
Enabled(p *Player[C]) bool
} }
// A BasicCard is a Card with fixed title, text, options, and optional post-option callback. // A BasicCard is a Card with fixed title, text, options, and optional
// post-option callback. It never does anything in particular when drawn.
type BasicCard[C StatsCollection] struct { type BasicCard[C StatsCollection] struct {
CardTitle Message CardTitle Message
IsUrgent bool
CardText Message CardText Message
CardOptions []CardOption[C] CardOptions []CardOption[C]
AfterOption func(p *Player[C], option CardOption[C]) error // AfterOption is given the card itself as its first argument.
AfterOption func(c Card[C], p *Player[C], option CardOption[C]) error
} }
func (b *BasicCard[C]) Title(p *Player[C]) (Message, error) { // Title implements Card.
return b.CardTitle, nil func (b *BasicCard[C]) Title(_ *Player[C]) Message {
return b.CardTitle
} }
func (b *BasicCard[C]) EventText(p *Player[C]) (Message, error) { // Urgent implements Card.
func (b *BasicCard[C]) Urgent(_ *Player[C]) bool {
return b.IsUrgent
}
// EventText implements Card.
func (b *BasicCard[C]) EventText(_ *Player[C]) (Message, error) {
return b.CardText, nil return b.CardText, nil
} }
// Options implements Card.
func (b *BasicCard[C]) Options(_ *Player[C]) ([]CardOption[C], error) { func (b *BasicCard[C]) Options(_ *Player[C]) ([]CardOption[C], error) {
return b.CardOptions, nil return b.CardOptions, nil
} }
// Then implements Card.
func (b *BasicCard[C]) Then(p *Player[C], option CardOption[C]) error { func (b *BasicCard[C]) Then(p *Player[C], option CardOption[C]) error {
if b.AfterOption == nil { if b.AfterOption == nil {
return nil return nil
} }
return b.AfterOption(p, option) return b.AfterOption(b, p, option)
}
// Drawn implements Card.
func (b *BasicCard[C]) Drawn(_ *Player[C]) bool {
return true
} }
// A BasicOption is a CardOption with fixed text, effects, and output. // A BasicOption is a CardOption with fixed text, effects, and output.
// It's always enabled.
type BasicOption[C StatsCollection] struct { type BasicOption[C StatsCollection] struct {
Text Message Text Message
Effect func(*Player[C]) error Effect func(*Player[C]) error
@ -84,6 +119,11 @@ func (b *BasicOption[C]) Enact(p *Player[C]) (Message, error) {
return b.Output, b.Effect(p) return b.Output, b.Effect(p)
} }
// Enabled implements CardOption.
func (b *BasicOption[C]) Enabled(p *Player[C]) bool {
return true
}
// OptionFunc attaches a fixed prompt to an Enact-like function. Unlike // OptionFunc attaches a fixed prompt to an Enact-like function. Unlike
// BasicOption, the enactment function provided to OptionFunc returns // BasicOption, the enactment function provided to OptionFunc returns
// the text that should be output as a result of the action, so it is // the text that should be output as a result of the action, so it is
@ -97,10 +137,17 @@ type optionFunc[C StatsCollection] struct {
f func(*Player[C]) (Message, error) f func(*Player[C]) (Message, error)
} }
// OptionText implements CardOption.
func (o *optionFunc[C]) OptionText(p *Player[C]) (Message, error) { func (o *optionFunc[C]) OptionText(p *Player[C]) (Message, error) {
return o.text, nil return o.text, nil
} }
// Enact implements CardOption.
func (o *optionFunc[C]) Enact(p *Player[C]) (Message, error) { func (o *optionFunc[C]) Enact(p *Player[C]) (Message, error) {
return o.f(p) return o.f(p)
} }
// Enabled implements CardOption.
func (o *optionFunc[C]) Enabled(p *Player[C]) bool {
return true
}

View File

@ -23,7 +23,7 @@ const (
// The Deck stores cards yet-to-be-dealt. // The Deck stores cards yet-to-be-dealt.
type Deck[C StatsCollection] struct { type Deck[C StatsCollection] struct {
cards []Card[C] cards []Card[C]
rand rand.Rand rand *rand.Rand
} }
// Len returns the number of cards in the Deck. // Len returns the number of cards in the Deck.
@ -31,8 +31,8 @@ func (d *Deck[C]) Len() int {
return len(d.cards) return len(d.cards)
} }
// Insert puts a card at a specific location in the Deck. The card previously // Insert puts one or more cards at a specific location in the Deck. Cards
// at that location and all locations after are shifted one card later. // at that location and all locations after are shifted deeper into the deck.
// Negative indexes are counted from the bottom of the deck. BottomOfDeck is // Negative indexes are counted from the bottom of the deck. BottomOfDeck is
// a sentinel value for the bottommost position; -1 is one card above. // a sentinel value for the bottommost position; -1 is one card above.
// //
@ -44,7 +44,7 @@ func (d *Deck[C]) Len() int {
// WarningTopClamped. Like all warnings, these can be safely ignored and the // WarningTopClamped. Like all warnings, these can be safely ignored and the
// program is in a well-defined state, but you may want to check for them // program is in a well-defined state, but you may want to check for them
// if you expect some other behavior. // if you expect some other behavior.
func (d *Deck[C]) Insert(idx int, card Card[C]) error { func (d *Deck[C]) Insert(idx int, card ...Card[C]) error {
var errs ErrorCollector var errs ErrorCollector
// Calculate actual target index. // Calculate actual target index.
switch { switch {
@ -60,14 +60,7 @@ func (d *Deck[C]) Insert(idx int, card Card[C]) error {
idx += d.Len() idx += d.Len()
} }
// remaining case: 0 <= idx <= d.Len(), which is a normal forward insert index. // remaining case: 0 <= idx <= d.Len(), which is a normal forward insert index.
d.cards = InsertInto(d.cards, idx, card...)
// Place new card on bottom and "bubble" into position.
// Takes O(N) time. If this turns out to be a problem, implement a more
// efficient data structure.
d.cards = append(d.cards, card)
for i := len(d.cards) - 1; i > idx; i-- {
d.cards[i], d.cards[i-1] = d.cards[i-1], d.cards[i]
}
return errs.Emit() return errs.Emit()
} }
@ -171,19 +164,19 @@ func (d *Deck[C]) InsertRandomRange(loFrac, hiFrac float64, card Card[C]) error
loFrac, hiFrac = hiFrac, loFrac loFrac, hiFrac = hiFrac, loFrac
} }
if loFrac < 0.0 { if loFrac < 0.0 {
errs.Add(Warningf("%w: loFrac was %d", WarningTopClamped, loFrac)) errs.Add(Warningf("%w: loFrac was %f", WarningTopClamped, loFrac))
loFrac = 0.0 loFrac = 0.0
} }
if loFrac > 1.0 { if loFrac > 1.0 {
errs.Add(Warningf("%w: loFrac was %d", WarningBottomClamped, loFrac)) errs.Add(Warningf("%w: loFrac was %f", WarningBottomClamped, loFrac))
loFrac = 1.0 loFrac = 1.0
} }
if hiFrac < 0.0 { if hiFrac < 0.0 {
errs.Add(Warningf("%w: hiFrac was %d", WarningTopClamped, hiFrac)) errs.Add(Warningf("%w: hiFrac was %f", WarningTopClamped, hiFrac))
hiFrac = 0.0 hiFrac = 0.0
} }
if hiFrac > 1.0 { if hiFrac > 1.0 {
errs.Add(Warningf("%w: hiFrac was %d", WarningBottomClamped, hiFrac)) errs.Add(Warningf("%w: hiFrac was %f", WarningBottomClamped, hiFrac))
hiFrac = 1.0 hiFrac = 1.0
} }
@ -203,3 +196,86 @@ func (d *Deck[C]) InsertRandomRange(loFrac, hiFrac float64, card Card[C]) error
errs.Add(d.Insert(slot, card)) errs.Add(d.Insert(slot, card))
return errs.Emit() return errs.Emit()
} }
// Shuffle completely shuffles the deck. If the deck has one or fewer cards,
// this returns WarningTooFewCards since nothing can be shuffled.
func (d *Deck[C]) Shuffle() error {
if len(d.cards) < 2 {
return WarningTooFewCards
}
ShuffleAll(d.cards, d.rand)
return nil
}
// ShufflePart shuffles the `n` cards of the deck starting at `loc`.
// If the provided range doesn't fit in the deck, this returns
// WarningTopClamped and/or WarningBottomClamped. If the eventual range
// of cards to be shuffled (after any off-the-end issues are corrected)
// is one or less, this returns WarningTooFewCards since nothing can
// be shuffled.
func (d *Deck[C]) ShufflePart(loc, n int) error {
if n < 2 {
// Nothing to do.
return WarningTooFewCards
}
var errs ErrorCollector
if loc < 0 {
errs.Add(Warningf("%w: loc was %d", WarningTopClamped, loc))
loc = 0
}
if loc+n > d.Len() {
errs.Add(Warningf("%w: deck size %d does not have %d cards at and after location %d",
WarningBottomClamped, len(d.cards), n, loc))
n = d.Len() - loc
// Now is there anything to do?
if n < 2 {
errs.Add(WarningTooFewCards)
return errs.Emit()
}
}
ShufflePart(d.cards, d.rand, loc, n)
return nil
}
// ShuffleRange shuffles the cards between the specified fractions of
// the deck; the top of the deck is 0.0 and the bottom of the deck is
// 1.0. This rounds "outward" -- "partial" cards at each end are counted.
// This can return the same warnings ShufflePart can in the same circumstances
// and may also complain about a backwards range.
func (d *Deck[C]) ShuffleRange(loFrac, hiFrac float64) error {
var errs ErrorCollector
if loFrac > hiFrac {
errs.Add(Warningf("%w: %f > %f", WarningBackwardsRange, loFrac, hiFrac))
loFrac, hiFrac = hiFrac, loFrac
}
low := int(math.Floor(loFrac * float64(d.Len())))
high := int(math.Ceil(hiFrac * float64(d.Len())))
n := 1 + high - low
errs.Add(d.ShufflePart(low, n))
return errs.Emit()
}
// ShuffleTop uses ShuffleRange to shuffle the top frac (between 0.0 and 1.0)
// of the deck. See ShuffleRange and ShufflePart for information on
// rounding and warnings.
func (d *Deck[C]) ShuffleTop(frac float64) error {
return d.ShuffleRange(0.0, frac)
}
// ShuffleBottom uses ShuffleRange to shuffle the bottom frac (between 0.0 and
// 1.0) of the deck. See ShuffleRange and ShufflePart for information on
// rounding and warnings.
func (d *Deck[C]) ShuffleBottom(frac float64) error {
return d.ShuffleRange(frac, 1.0)
}
// Strip removes all cards from the deck where shouldRemove returns true.
// shouldRemove is provided with each card in the deck and its index.
// It returns how many cards were stripped from the deck.
func (d *Deck[C]) Strip(shouldRemove func(idx int, c Card[C]) bool) int {
origLen := d.Len()
d.cards = Strip(d.cards, shouldRemove)
return origLen - d.Len()
}

View File

@ -52,6 +52,15 @@ func Warningf(f string, args ...any) *Warning {
return &Warning{fmt.Errorf(f, args...)} return &Warning{fmt.Errorf(f, args...)}
} }
// IsSeriousError returns whether e is a non-warning error. If e is nil, this
// returns false.
func IsSeriousError(e error) bool {
if e == nil {
return false
}
return !errors.Is(e, AnyWarning)
}
// A Failure is an error that is definitely not a Warning. If a Warning is // A Failure is an error that is definitely not a Warning. If a Warning is
// wrapped in a Failure, it stops being a Warning. // wrapped in a Failure, it stops being a Warning.
type Failure struct { type Failure struct {
@ -144,8 +153,11 @@ func (f Failure) As(target any) bool {
// - Otherwise, it returns an error that combines all the errors it collected. // - Otherwise, it returns an error that combines all the errors it collected.
// The aggregated error is a Warning if and only if all collected errors // The aggregated error is a Warning if and only if all collected errors
// were also warnings. // were also warnings.
//
// An ErrorCollector's Errs should not be written by anything other than Add,
// or HasFailure may get out of sync.
type ErrorCollector struct { type ErrorCollector struct {
errs []error Errs []error
hasFailure bool hasFailure bool
} }
@ -157,12 +169,12 @@ func (ec *ErrorCollector) Add(e error) {
if !errors.Is(e, AnyWarning) { if !errors.Is(e, AnyWarning) {
ec.hasFailure = true ec.hasFailure = true
} }
ec.errs = append(ec.errs, e) ec.Errs = append(ec.Errs, e)
} }
// IsEmpty reports whether ec has zero accumulated errors/warnings. // IsEmpty reports whether ec has zero accumulated errors/warnings.
func (ec *ErrorCollector) IsEmpty() bool { func (ec *ErrorCollector) IsEmpty() bool {
return len(ec.errs) == 0 return len(ec.Errs) == 0
} }
// HasFailure reports whether ec has at least one non-warning error. // HasFailure reports whether ec has at least one non-warning error.
@ -181,16 +193,16 @@ func (ec *ErrorCollector) HasFailure() bool {
// errors.Is does not erroneously represent a failure as a warning because it // errors.Is does not erroneously represent a failure as a warning because it
// contains a warning as a subcomponent. // contains a warning as a subcomponent.
func (ec *ErrorCollector) Emit() error { func (ec *ErrorCollector) Emit() error {
if len(ec.errs) == 0 { if len(ec.Errs) == 0 {
return nil return nil
} }
if len(ec.errs) == 1 { if len(ec.Errs) == 1 {
return ec.errs[0] return ec.Errs[0]
} }
if ec.HasFailure() { if ec.HasFailure() {
return aggregateFailure(ec.errs) return aggregateFailure(ec.Errs)
} }
return &aggregateError{ec.errs} // all these are recognizable warnings return &aggregateError{ec.Errs} // all these are recognizable warnings
} }
// An aggregateError is a collection of errors that is itself an error. // An aggregateError is a collection of errors that is itself an error.

167
cardsim/infopanel.go Normal file
View File

@ -0,0 +1,167 @@
package cardsim
import (
"fmt"
"strings"
)
// An InfoPanel displays some set of stats to the player. It does
// not consume an action. It must not advance the state of the game
// in any way.
type InfoPanel[C StatsCollection] interface {
// Title returns the title of this InfoPanel, which is also used as the
// label presented to the player to access this panel.
Title(p *Player[C]) Message
// Info returns the displayable contents of this InfoPanel. A nil Message
// in the output is interpreted as a paragraph break.
Info(p *Player[C]) ([]Message, error)
}
// A StatFilter decides whether to show a specific stat in a BasicStatsPanel
// (and maybe other kinds of stats panels, if they choose to support this).
type StatFilter[C StatsCollection] func(p *Player[C], s Stat) bool
// BasicStatsPanel shows some or all of the stats output by C, under
// a fixed name, introduced by a specific prompt. Stats are shown as a two
// column table with the name, then the value.
type BasicStatsPanel[C StatsCollection] struct {
// Name stores the name of this stats panel, which is also shown in the menu.
Name Message
// Intro stores a message to always display before the stats. Optional.
Intro Message
// Filter stores a function to decide what stats to show. If this is not
// provided, the BasicStatsPanel uses VisibleOrDebug by default.
Filter StatFilter[C]
}
// VisibleOrDebug returns whether s is Visible or p is in debug mode,
// so a debug-mode player shows all stats.
func VisibleOrDebug[C StatsCollection](p *Player[C], s Stat) bool {
return p.DebugLevel > 0 || s.Visible()
}
// Title implements `InfoPanel[C]` by returning b's `Name`.
func (b *BasicStatsPanel[C]) Title(p *Player[C]) Message {
return b.Name
}
// Info implements `InfoPanel[C]` by dumpiing p.Stats, showing those items for
// whch b.Filter returns true.
func (b *BasicStatsPanel[C]) Info(p *Player[C]) ([]Message, error) {
stats := p.Stats.Stats()
cached := make([]cachedStat, 0, len(stats))
longestName := 0
filter := b.Filter
if filter == nil {
filter = VisibleOrDebug[C]
}
for _, s := range stats {
if !filter(p, s) {
continue
}
name := s.StatName()
if len(name) > longestName {
longestName = len(name)
}
cached = append(cached, cachedStat{name, s.String(), s.Visible()})
}
if len(cached) == 0 {
return []Message{b.Intro, nil, MsgStr("No stats available")}, nil
}
ret := make([]Message, 0, 2+len(cached))
ret = append(ret, b.Intro, nil)
for _, s := range cached {
ret = append(ret, MsgStr(s.output(longestName)))
}
return ret, nil
}
// cachedStat is an implementation detail of BasicStatsPanel.Info. It stores the
// values out of a stat so they do not need to be recalculated, in case they
// are expensive to calculate or the filter deciding which stats to output
// was expensive.
type cachedStat struct {
name string
value string
visible bool
}
// output returns a string representing this stat, right-aligning the name in
// a nameWidth-wide field and prefixing it with a bullet: "•" for a visible
// stat and "◦" for an invisible stat. If nameWidth is 0, the name and
// colon are omitted. If it is negative, the name is emitted as-is with
// no alignment. If it is too short for the name and it is nonzero,
// it is truncated with "⋯".
func (c cachedStat) output(nameWidth int) string {
bullet := "◦"
if c.visible {
bullet = "•"
}
if nameWidth == 0 {
return fmt.Sprintf("%s %s", bullet, c.value)
}
name := c.name
if len(name) < nameWidth {
name = strings.Repeat(" ", nameWidth-len(name)) + name
}
if len(name) > nameWidth && nameWidth > 0 {
name = name[:nameWidth-1] + "⋯"
}
return fmt.Sprintf("%s %s: %s", bullet, name, c.value)
}
// StatsNamed returns a StatFilter[C] matching any stat with a listed name.
func StatsNamed[C StatsCollection](names ...string) StatFilter[C] {
nameSet := make(map[string]bool)
for _, n := range names {
nameSet[n] = true
}
return func(_ *Player[C], s Stat) bool {
return nameSet[s.StatName()]
}
}
// VisibleOrDebugStatsNamed returns a StatFilter[C] matching any visible stat
// with a listed name, or any stat with a listed name if the player is in
// debug mode.
func VisibleOrDebugStatsNamed[C StatsCollection](names ...string) StatFilter[C] {
return All(VisibleOrDebug[C], StatsNamed[C](names...))
}
// All returns a StatFilter[C] that requires a Stat to match all provided
// filters. If no filters are provided, All matches every Stat (it's very easy
// to meet every requirement when there are no requirements).
func All[C StatsCollection](ff ...StatFilter[C]) StatFilter[C] {
return func(p *Player[C], s Stat) bool {
for _, f := range ff {
if !f(p, s) {
return false
}
}
return true
}
}
// Any returns a StatFilter[C] that requires a Stat to match any one or more of
// the filters provided. If no filters are provided, Any never matches a stat
// (it's very hard to meet at least one requirement out when there are no
// requirements).
func Any[C StatsCollection](ff ...StatFilter[C]) StatFilter[C] {
return func(p *Player[C], s Stat) bool {
for _, f := range ff {
if f(p, s) {
return true
}
}
return false
}
}

View File

@ -1,6 +1,9 @@
package cardsim package cardsim
import "fmt" import (
"fmt"
"strings"
)
// Message is an opaque interface representing a displayable message. // Message is an opaque interface representing a displayable message.
// Using an interface here allows for implementation of new message display // Using an interface here allows for implementation of new message display
@ -25,3 +28,50 @@ func MsgStr(s string) Message {
func Msgf(f string, args ...any) Message { func Msgf(f string, args ...any) Message {
return stringMessage(fmt.Sprintf(f, args...)) return stringMessage(fmt.Sprintf(f, args...))
} }
// A SpecialMessage is a specific, uniquely identifiable message.
type SpecialMessage struct {
msg Message
}
// String implements Message.
func (s *SpecialMessage) String() string {
if s == nil {
return ""
}
return s.msg.String()
}
// Messages that various display surfaces or other components may have a special interpretation of
// and identify specifically. These are largely sentinel values. Nil is a paragraph break.
var (
SectionBreak = &SpecialMessage{MsgStr(" -------------------------------------------------------------------- ")}
ChapterBreak = &SpecialMessage{MsgStr(" ==================================================================== ")}
)
// IsSpecialMessage returns whether a provided Message is a specific SpecialMessage.
func IsSpecialMessage(m Message, s *SpecialMessage) bool {
if m == nil {
return s == nil
}
if s2, ok := m.(*SpecialMessage); ok {
return s == s2
}
return false
}
// MultiMessage is a sequence of messages treated like one message.
type MultiMessage []Message
func (m MultiMessage) String() string {
s := make([]string, len(m))
for i, msg := range m {
if msg == nil {
s[i] = ""
continue
}
s[i] = msg.String()
continue
}
return strings.Join(s, "\n")
}

View File

@ -1,16 +1,482 @@
package cardsim package cardsim
import "math/rand" import (
"errors"
"fmt"
"math/rand"
"time"
)
// Player stores all gameplay state for one player. var (
ErrUncooperativeCards = errors.New("a milion cards refused to join the hand")
ErrInvalidCard = errors.New("invalid card specified")
ErrInvalidChoice = errors.New("invalid choice specified")
ErrNotUrgent = errors.New("action not urgent when urgent card is available")
ErrNoActions = errors.New("no actions remaining")
WarningStalemate = errors.New("no actions can be taken")
)
// Player stores all gameplay state for one player at a specific point in time.
// Game-specific data is stored in Stats.
//
// Player is a generic type -- see https://go.dev/blog/intro-generics for more
// information on how these work. Think of "Player" as a "type of type" --
// when you create one, you tell it what kind of data it needs to keep for
// the simulation itself, and each Player that works with a different kind of
// data is a different kind of Player and the compiler will help you with that.
// This is the same idea as "slice of something" or "map from something to
// something" -- different kinds of Players are different from each other and
// "know" what type of data they use, so the compiler can tell you if you're
// using the wrong type.
//
// Generic types have to use a placeholder to represent the type (or types --
// consider maps, which have both keys and values) that will be more specific
// when the type is actually used. They're called "type parameters", like
// function parameters, because they're the same kind of idea. A function puts
// its parameters into variables so you can write a function that works with
// whatever data it gets; a generic type takes type parameters and represents
// them with type placeholders so you can write a *type* that works with
// whatever specific other types it gets.
//
// Just like function parameters have a type that says what kind of data the
// function works with, type parameters have a "type constraint" that says what
// kind of types the generic type works with. Go already has a familiar way
// to express the idea of "what a type has to do": `interface`. In Go, type
// constraints are just interfaces.
//
// But wait, why use generics at all? Can't we just use an interface in the
// normal way instead of doing this thing? Well, yes, we could, but then the
// compiler doesn't know that the "real types" for things matching these
// interfaces all have to actually be the same type. The compiler will stop
// you from putting an `Orange` into a `[]Apple`, but it wouldn't stop you from
// putting a `Fruit` into a `[]Fruit` because, well, of course it wouldn't,
// they're the same type.
//
// Different simulation games made with `cardsim` are different. Rules made for
// simulating the economy of a kobold colony and mine wouldn't work at all with
// data for a simulation about three flocks of otter-gryphons having a
// territory conflict over a river full of fish. By using generics, the compiler
// can recognize functions and data and types intended for different simulation
// games and prevent you from using the wrong one, when it wouldn't be able to
// if all this stuff was written for "some simulation game, don't care what".
//
// Generic interfaces (like `Card[C]`, `Rule[C]`, `InfoPanel[C]`, and more)
// don't mean you have to write generics of your own. It's exactly the opposite!
// Because the interface has this extra type in it, you only need to implement
// the specific kind of interface that works with your game. There's more detail
// on this in the comment on `Rule[C]`.
type Player[C StatsCollection] struct { type Player[C StatsCollection] struct {
Stats C // Stats stores simulation-specific state.
Name string Stats C
Deck *Deck[C]
Hand []Card[C] // Name stores the player's name.
HandLimit int Name string
Rules *RuleCollection[C]
Rand rand.Rand // Rand is a source of randomness that other components can use.
Turn int Rand *rand.Rand
PendingMessages []Message
Deck *Deck[C]
Hand []Card[C]
TurnNumber int
State GameState
// HandLimit is number of cards to draw to at the start of each turn.
// If the player has more cards than this already, none will be drawn,
// but the player will keep them all.
//
// If this is 0 or less and the player has no cards in hand, no permanent
// actions available, and must take an action, the game ends in stalemate.
HandLimit int
// ActionsPerTurn is what ActionsRemaining resets to at the start of each
// turn. If this is 0 or less at the start of a turn, the game ends in
// stalemate. Activating a card or permanent action spends an action, but
// the card or action itself can counter this by changing the player's
// ActionsRemaining by giving the action back -- or force the turn to
// progress immediately to simulation by setting it to 0.
ActionsPerTurn int
ActionsRemaining int
// PermanentActions are an "extra hand" of cards that are not discarded when
// used. An Urgent PermanentAction does not block non-urgent actions and
// cards in hand from being used, but it can be used even when an urgent
// card is in the hand.
PermanentActions []Card[C]
// InfoPanels lists informational views available to the player. The Prompt
// is the InfoPanel shown before the main action menu.
InfoPanels []InfoPanel[C]
Prompt InfoPanel[C]
// Rules are the simulation rules executed every turn after the player has
// run out of remaining actions. See `RuleCollection`'s documentation for
// more information about how rule execution works.
Rules *RuleCollection[C]
// Temporary messages are shown *before* the Prompt. They're cleared just
// before executing rules for the turn, so rules adding to TemporaryMessages
// are creating messages that will show up for the next turn. Temporary
// panels are cleared out at the same time as temporary messages; when
// available, they are listed separately from standard panels (before them).
TemporaryMessages []Message
TemporaryPanels []InfoPanel[C]
// DebugLevel stores how verbose the game should be about errors. If this
// is greater than 0, invisible stats will usually be shown to the player
// (this is up to individual info panels, though). If this is -1 or lower,
// warning messages will not be displayed.
DebugLevel int
}
// GameState represents various states a player's Game can be in.
type GameState int
const (
// The game has not started.
GameUninitialized = GameState(iota)
// The game is ready to play.
GameActive
// The game is over and the player has lost.
GameLost
// The game is over and the player has won.
GameWon
// The game is over because of an error.
GameCrashed
// The game is over because the player cannot take any actions.
GameStalled
)
// InitPlayer returns a mostly-uninitialized Player with the fields
// that require specific initialization already configured and the
// provided StatsCollection (if any) already assigned to its Stats.
// Most fields are not configured and need to be assigned after this.
//
// The Player is initialized with an empty deck, empty rule collection,
// a hand limit of 1, an actions-per-turn limit of 1, and a random
// number generator seeded with the nanosecond component of the current time.
// The Deck shares this random number generator.
func InitPlayer[C StatsCollection](stats C) *Player[C] {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
return &Player[C]{
Stats: stats,
Rand: r,
Deck: &Deck[C]{rand: r},
HandLimit: 1,
ActionsPerTurn: 1,
Rules: NewRuleCollection[C](),
}
}
// Over returns whether this state represents a game that is over.
func (g GameState) Over() bool {
return g == GameLost || g == GameWon || g == GameCrashed || g == GameStalled
}
// ChapterBreak apends a chapter break to p.TemporaryMessages, unless it is
// empty or the most recent non-nil message is already a chapter break.
func (p *Player[C]) ChapterBreak() {
for i := len(p.TemporaryMessages) - 1; i >= 0; i-- {
m := p.TemporaryMessages[i]
if IsSpecialMessage(m, ChapterBreak) {
return
}
if p.TemporaryMessages[i] != nil {
p.TemporaryMessages = append(p.TemporaryMessages, ChapterBreak)
return
}
}
// No non-nil messages -- nothing to do.
}
// SectionBreak apends a section break to p.TemporaryMessages, unless it is
// empty or the most recent non-nil message is already a section/chapter break.
func (p *Player[C]) SectionBreak() {
for i := len(p.TemporaryMessages) - 1; i >= 0; i-- {
m := p.TemporaryMessages[i]
if IsSpecialMessage(m, ChapterBreak) || IsSpecialMessage(m, SectionBreak) {
return
}
if m != nil {
p.TemporaryMessages = append(p.TemporaryMessages, SectionBreak)
return
}
}
// No non-nil messages -- nothing to do.
}
// Simulate executes the simulation up to the start of the next turn. If the
// simulation crashes, the game state becomes GameCrashed. This returns any
// generated errors; if the debugging mode is 0 or greater, they also become
// temporary messages for the next turn.
func (p *Player[C]) Simulate() error {
var errs ErrorCollector
p.TemporaryMessages = nil
p.TemporaryPanels = nil
errs.Add(p.Rules.Run(p))
errs.Add(p.StartNextTurn())
if errs.HasFailure() {
p.State = GameCrashed
}
if p.DebugLevel > 0 && !errs.IsEmpty() {
p.ChapterBreak()
p.TemporaryMessages = append(p.TemporaryMessages, Msgf("%d ERRORS AND WARNINGS while simulating turn:", len(errs.Errs)))
for i, e := range errs.Errs {
yikes := " "
if IsSeriousError(e) {
yikes = "!"
}
p.TemporaryMessages = append(p.TemporaryMessages, Msgf("%s[%d]:\t%v", yikes, i, e))
}
}
return errs.Emit()
}
// StartNextTurn increments the turn counter, resets the action counter,
// and draws back up to full. If the player cannot take any actions after
// drawing is complete, the game stalls. (If a drawn card would like to
// force the player to take no actions for a turn, the best approach is to
// make that card Urgent and make it reset ActionsRemaining to 0 after it runs.)
func (p *Player[C]) StartNextTurn() error {
var errs ErrorCollector
p.TurnNumber++
p.ActionsRemaining = p.ActionsPerTurn
errs.Add(p.FillHand())
if len(p.Hand)+len(p.PermanentActions) == 0 {
p.State = GameStalled
errs.Add(Warningf("%w: no cards in hand, no permanent actions", WarningStalemate))
}
if p.ActionsRemaining == 0 {
p.State = GameStalled
errs.Add(Warningf("%w: 0 actions available in the turn", WarningStalemate))
}
return errs.Emit()
}
// Draw draws a card into the hand, informing the card that it has been drawn.
// If more than a million cards refuse to enter the hand, this crashes with
// ErrUncooperativeCards. If the deck does not have enough cards, this
// returns WarningTooFewCards.
func (p *Player[C]) Draw() error {
for attempts := 0; attempts < 1000000; attempts++ {
if p.Deck.Len() == 0 {
return WarningTooFewCards
}
c := p.Deck.Draw()
if c.Drawn(p) {
p.Hand = append(p.Hand, c)
return nil
}
}
return ErrUncooperativeCards
}
// FillHand draws up to the hand limit, informing cards that they have been
// drawn. If more than a million cards refuse to enter the hand, this crashes
// with ErrUncooperativeCards. If the deck does not have enough cards, this
// returns WarningTooFewCards.
func (p *Player[C]) FillHand() error {
var lastErr error
for p.Deck.Len() > 0 && len(p.Hand) < p.HandLimit {
lastErr = p.Draw()
if IsSeriousError(lastErr) {
return lastErr
}
}
if len(p.Hand) >= p.HandLimit {
return nil
}
return WarningTooFewCards
}
// HasUrgentCards returns whether any cards in the Hand think they are Urgent.
func (p *Player[C]) HasUrgentCards() bool {
for _, c := range p.Hand {
if c.Urgent(p) {
return true
}
}
return false
}
// EnactCardUnchecked executes a card choice, removes it from the hand, and
// decrements the ActionsRemaining. It does not check for conflicting Urgent
// cards or already being out of actions. If no such card or card choice
// exists, or the specified choice is not enabled, this returns nil and
// ErrInvalidCard/ErrInvalidChoice without changing anything. Otherwise, this
// returns the result of enacting the card. If enacting the card causes a
// serious error, the State becomes GameCrashed.
func (p *Player[C]) EnactCardUnchecked(cardIdx, choiceIdx int) (Message, error) {
if cardIdx < 0 || cardIdx >= len(p.Hand) {
return nil, fmt.Errorf("%w: no card #%d when %d cards in hand", ErrInvalidCard, cardIdx, len(p.Hand))
}
card := p.Hand[cardIdx]
var errs ErrorCollector
options, err := card.Options(p)
if IsSeriousError(err) {
p.State = GameCrashed
return nil, err
}
errs.Add(err)
if choiceIdx < 0 || choiceIdx > len(options) {
errs.Add(fmt.Errorf("%w: no option #%d on card #%d with %d options", ErrInvalidChoice, choiceIdx, cardIdx, len(options)))
return nil, errs.Emit()
}
chosen := options[choiceIdx]
if !chosen.Enabled(p) {
errs.Add(fmt.Errorf("%w: option %d on card %d was not enabled", ErrInvalidChoice, choiceIdx, cardIdx))
return nil, errs.Emit()
}
p.Hand = DeleteFrom(p.Hand, cardIdx)
p.ActionsRemaining--
ret, err := options[choiceIdx].Enact(p)
errs.Add(err)
if IsSeriousError(err) {
p.State = GameCrashed
return ret, errs.Emit()
}
err = card.Then(p, options[choiceIdx])
errs.Add(err)
if IsSeriousError(err) {
p.State = GameCrashed
}
return ret, errs.Emit()
}
// EnactCard executes a card choice, removes it from the hand, and decrements
// the ActionsRemaining. If the card is not Urgent but urgent cards are
// available, or the player is out of actions, this returns ErrNotUrgent or
// ErrNoActions. Otherwise, this acts like EnactCardUnchecked.
func (p *Player[C]) EnactCard(cardIdx, choiceIdx int) (Message, error) {
if p.ActionsRemaining <= 0 {
return nil, ErrNoActions
}
if cardIdx < 0 || cardIdx >= len(p.Hand) {
return nil, fmt.Errorf("%w: no card #%d when %d cards in hand", ErrInvalidCard, cardIdx, len(p.Hand))
}
if !p.Hand[cardIdx].Urgent(p) && p.HasUrgentCards() {
return nil, ErrNotUrgent
}
return p.EnactCardUnchecked(cardIdx, choiceIdx)
}
// EnactPermanentActionUnchecked executes a permanently-available action and
// decrements the ActionsRemaining. It does not check for conflicting Urgent
// cards or 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. Otherwise, this returns the
// result of enacting the permanent action. If enacting the card causes a
// serious error, the State becomes GameCrashed.
func (p *Player[C]) EnactPermanentActionUnchecked(actionIdx, choiceIdx int) (Message, error) {
if actionIdx < 0 || actionIdx >= len(p.PermanentActions) {
return nil, fmt.Errorf("%w: no action #%d when %d permanent actions exist", ErrInvalidCard, actionIdx, len(p.PermanentActions))
}
card := p.PermanentActions[actionIdx]
var errs ErrorCollector
options, err := card.Options(p)
if IsSeriousError(err) {
p.State = GameCrashed
return nil, err
}
errs.Add(err)
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)))
return nil, errs.Emit()
}
chosen := options[choiceIdx]
if !chosen.Enabled(p) {
errs.Add(fmt.Errorf("%w: option #%d on permanent action #%d is not enabled", ErrInvalidChoice, choiceIdx, actionIdx))
return nil, errs.Emit()
}
p.ActionsRemaining--
ret, err := chosen.Enact(p)
errs.Add(err)
if IsSeriousError(err) {
p.State = GameCrashed
return ret, errs.Emit()
}
err = card.Then(p, chosen)
errs.Add(err)
if IsSeriousError(err) {
p.State = GameCrashed
}
return ret, errs.Emit()
}
// EnactPermanentAction executes a permanently-available card and decrements
// the ActionsRemaining. If the action is not Urgent but urgent cards are
// available, or the player is out of actions, this returns ErrNotUrgent or
// ErrNoActions. Otherwise, this acts like EnactPermanentActionUnchecked.
func (p *Player[C]) EnactPermanentAction(actionIdx, choiceIdx int) (Message, error) {
if p.ActionsRemaining <= 0 {
return nil, ErrNoActions
}
if actionIdx < 0 || actionIdx >= len(p.PermanentActions) {
return nil, fmt.Errorf("%w: no action #%d when %d permanent actions available", ErrInvalidCard, actionIdx, len(p.PermanentActions))
}
if !p.PermanentActions[actionIdx].Urgent(p) && p.HasUrgentCards() {
return nil, ErrNotUrgent
}
return p.EnactPermanentActionUnchecked(actionIdx, choiceIdx)
}
// ReportError adds an error to the temporary messages, depending on
// its severity and debug settings:
//
// - If the error is nil, this never does anything.
// - If the error is serious, this emits the error if the debug level is
// -1 or greater.
// - If the error is only a warning, this emits the error if the debug
// level is 0 or greater.
func (p *Player[C]) ReportError(e error) {
if e == nil || p.DebugLevel < -1 {
return
}
if p.DebugLevel < 0 && !IsSeriousError(e) {
return
}
p.ChapterBreak()
severity := "[Warning]"
if IsSeriousError(e) {
severity = "[ERROR]"
}
p.TemporaryMessages = append(p.TemporaryMessages, Msgf("%s: %v", severity, e))
}
// CanAct returns whether the player has actions theoretically available.
func (p *Player[C]) CanAct() bool {
return p.ActionsRemaining > 0 && (len(p.Hand) > 0 || len(p.PermanentActions) > 0)
}
// Debug adds a message to the player's temporary messages if their debug level
// is at least the level specified.
func (p *Player[C]) Debug(minLevel int, msg Message) {
if p.DebugLevel < minLevel || msg == nil {
return
}
p.TemporaryMessages = append(p.TemporaryMessages, msg)
}
// Emit adds a message to the player's temporary messages.
func (p *Player[C]) Emit(msg Message) {
if msg == nil {
return
}
p.TemporaryMessages = append(p.TemporaryMessages, msg)
} }

View File

@ -7,6 +7,27 @@ import (
) )
// A Rule implements an operation run on every game turn. // A Rule implements an operation run on every game turn.
//
// Rule[C] is a generic interface. Like any other generic type, it describes a
// family of related types: each different kind of StatsCollection that Rule
// could pertain to is the basis of a distinct type of Rule.
//
// When implementing a generic interface, you do not need to implement a
// generic type. In the case of Rule, you are likely to be writing rules for a
// specific simulation. That simulation will have some associated
// StatsCollection type. The rules you write will only need to implement the
// variation of Rule that pertains specifically to that type.
//
// For example, if your `StatsCollection` type is `KoboldMineData`, then rules
// for the simulation referring to it would implement `Rule[KoboldMineData]`
// only. So the `Enact` function you implment would take an argument of type
// `*Player[KoboldMineData]`, not some undefined type `C` that could be any
// StatsCollection. Since it takes a `*Player[KoboldMineData]` as an argument,
// you then know that the player's `Stats` field is not just any
// StatsCollection, it is KoboldMineData specifically. The compiler won't
// require you to convert from "some `StatsCollection`" to "`KoboldMineData`
// specifically" when using the `Player[KoboldMineData].Stats` field,
// because the type of that field is already `KoboldMineData`.
type Rule[C StatsCollection] interface { type Rule[C StatsCollection] interface {
// Label is an internal name the rule can be recognized by. // Label is an internal name the rule can be recognized by.
// Some things may be easier if it is unique, but it does not have to be. // Some things may be easier if it is unique, but it does not have to be.
@ -120,7 +141,7 @@ func (r *RuleCollection[C]) performInsert(k *keyedRule[C]) {
r.rules[k.id] = k r.rules[k.id] = k
s := r.byStep[k.Step()] s := r.byStep[k.Step()]
if s == nil { if len(s) == 0 {
r.steps = nil r.steps = nil
} }
s = append(s, k.id) s = append(s, k.id)
@ -305,7 +326,7 @@ func (r *RuleCollection[C]) Run(p *Player[C]) error {
steps := r.steps steps := r.steps
if steps == nil { if steps == nil {
// Step set changed, recalculate. // Step set changed, recalculate.
steps := make([]int, 0, len(r.byStep)) steps = make([]int, 0, len(r.byStep))
for step := range r.byStep { for step := range r.byStep {
steps = append(steps, step) steps = append(steps, step)
} }
@ -313,18 +334,21 @@ func (r *RuleCollection[C]) Run(p *Player[C]) error {
r.steps = steps r.steps = steps
} }
p.Debug(2, Msgf("Executing steps: %v", steps))
var errs ErrorCollector var errs ErrorCollector
for _, step := range steps { for _, step := range steps {
stepRules := r.byStep[step] stepRules := r.byStep[step]
p.Rand.Shuffle(len(stepRules), func(i, j int) { p.Debug(3, Msgf("Executing step %d; length %d", step, len(stepRules)))
stepRules[i], stepRules[j] = stepRules[j], stepRules[i] ShuffleAll(stepRules, p.Rand)
})
var remove []RuleID var remove []RuleID
halt := false halt := false
for _, id := range stepRules { for _, id := range stepRules {
rule := r.rules[id] rule := r.rules[id]
p.Debug(4, Msgf("Executing rule %x (labeled %q)", id, rule.Label()))
err := rule.Enact(p) err := rule.Enact(p)
if err != nil { if err != nil {
p.Debug(2, Msgf("Rule %x (%q): error: %v", id, rule.Label(), err))
ignore := false ignore := false
if errors.Is(err, ErrDeleteRule) { if errors.Is(err, ErrDeleteRule) {
remove = append(remove, id) remove = append(remove, id)
@ -356,10 +380,14 @@ func (r *RuleCollection[C]) Run(p *Player[C]) error {
} }
} }
if halt { if halt {
return errs.Emit() ret := errs.Emit()
p.Debug(2, Msgf("Rules stopping early. Result: %v", ret))
return ret
} }
} }
return errs.Emit() ret := errs.Emit()
p.Debug(2, Msgf("Rules complete. Result: %v", ret))
return ret
} }
func (r *RuleCollection[C]) applyDelayedUpdates() { func (r *RuleCollection[C]) applyDelayedUpdates() {

147
cardsim/sliceutil.go Normal file
View File

@ -0,0 +1,147 @@
package cardsim
import (
"fmt"
"math/rand"
)
// InsertInto inserts one or more items into a slice at an arbitrary index.
// Items already in the slice past the target position move to later positons.
//
// Like `append`, this may move the underlying array and it produces a new
// slice header (under the hood, it uses `append`). It returns the new slice
// (the original is in an undefined state and should no longer be used).
//
// If loc is negative or more than one past the end of T, Insert panics.
func InsertInto[T any](slice []T, loc int, elements ...T) []T {
if loc < 0 || loc > len(slice) {
panic(fmt.Sprintf("can't Insert at location %d in %d-element slice", loc, len(slice)))
}
// is this a no-op?
if len(elements) == 0 {
return slice
}
// is this just an append?
if loc == len(slice) {
return append(slice, elements...)
}
offset := len(elements)
oldLen := len(slice)
newSize := oldLen + offset
if newSize <= cap(slice) {
// We can reslice in place.
slice = slice[:newSize]
// Scoot trailing to their new positions.
copy(slice[loc+offset:], slice[loc:oldLen])
// Insert the new elements.
copy(slice[loc:], elements)
return slice
}
// Reallocate. Do the normal thing of doubling the size as a minimum
// when increasing space for a dynamic array; this amortizes the
// cost of repeatedly reallocating and moving the slice.
newCap := cap(slice) * 2
if newCap < newSize {
newCap = newSize
}
newSlice := make([]T, newSize, newCap)
if loc > 0 {
copy(newSlice, slice[0:loc])
}
copy(newSlice[loc:], elements)
copy(newSlice[loc+offset:], slice[loc:])
return newSlice
}
// DeleteFrom deletes an item from a slice at an arbitrary index. Items after it
// scoot up to close the gap. This returns the modified slice (like Append).
//
// If the provided location is not a valid location in the slice, this panics.
func DeleteFrom[T any](slice []T, loc int) []T {
return DeleteNFrom(slice, loc, 1)
}
// DeleteNFrom deletes N items from a slice at an arbitrary index. Items after
// it scoot up to close the gap. This returns the modified slice (like Append).
//
// If the range of items that would be deleted is not entirely valid within the
// slice, this panics.
func DeleteNFrom[T any](slice []T, loc, n int) []T {
if loc < 0 || loc+n > len(slice) {
panic(fmt.Sprintf("can't delete %d elements from a %d-element slice at location %d", n, len(slice), loc))
}
// Easy cases.
if n == 0 {
return slice
}
if loc == 0 {
return slice[n:]
}
if loc+n == len(slice) {
return slice[0:loc]
}
// Is it shorter to move up or move down?
if len(slice)-loc-n > loc {
// Move forward -- the end is big.
copy(slice[n:], slice[:loc])
return slice[n:]
}
// Move backward -- the beginnng is big or they're the same size
// (and moving backwards preserves more usable append capacity later).
copy(slice[loc:], slice[loc+n:])
return slice[:len(slice)-n]
}
// ShuffleAll shuffles everything in slice, using the provided rand.Rand.
// If no rand.Rand is provided, this uses the default source.
func ShuffleAll[T any](slice []T, r *rand.Rand) {
shuffle := rand.Shuffle
if r != nil {
shuffle = r.Shuffle
}
shuffle(len(slice), func(i, j int) {
slice[i], slice[j] = slice[j], slice[i]
})
}
// ShufflePart shuffles the `n` elements of `slice` starting at `loc`
// in-place, using the provided rand.Rand. If the range of items to
// shuffle is not entirely within `slice`, this panics.
//
// If no rand.Rand is provided, this uses the default source.
func ShufflePart[T any](slice []T, r *rand.Rand, loc, n int) {
if loc < 0 || loc+n > len(slice) {
panic(fmt.Sprintf("can't shuffle %d elements from a %d-element slice at location %d", n, len(slice), loc))
}
if n < 1 {
return
}
ShuffleAll(slice[loc:loc+n], r)
}
// Strip iterates T, removing any element for which removeWhen returns true
// (when provided the index of the element and the element itself as arguments).
// It returns the stripped slice.
func Strip[T any](slice []T, removeWhen func(idx int, t T) bool) []T {
if len(slice) == 0 {
return nil
}
to := 0
for from, e := range slice {
if !removeWhen(from, e) {
if to != from {
slice[to] = slice[from]
}
to++
}
}
return slice[:to]
}

View File

@ -4,14 +4,20 @@ import (
"fmt" "fmt"
"reflect" "reflect"
"sort" "sort"
"strconv"
"strings" "strings"
"unicode"
"golang.org/x/exp/constraints"
) )
// A StatsCollection contains stats. // A StatsCollection contains stats.
type StatsCollection interface { type StatsCollection interface {
// Stats returns all the stats in this collection. It's okay for // Stats returns all the stats in this collection. It's okay for
// these to be copies rather than pointers. Stats will be presented // these to be copies rather than pointers. BasicStatsPanel presents
// to the player in this order. // stats to the player in this order. It's okay for this list to
// contain nil entries; these are interpreted as line breaks,
// section breaks, etc.
Stats() []Stat Stats() []Stat
} }
@ -112,9 +118,26 @@ 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.
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() {
@ -123,19 +146,122 @@ 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 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
} }
@ -171,3 +297,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
}

528
cardsim/terminalui.go Normal file
View File

@ -0,0 +1,528 @@
package cardsim
import (
"fmt"
"os"
"strconv"
"strings"
)
func RunSimpleTerminalUI[C StatsCollection](p *Player[C]) error {
err := p.StartNextTurn()
if p.DebugLevel < 1 && IsSeriousError(err) {
return err
}
p.ReportError(err)
for {
for p.CanAct() {
isCard, cardIdx, choiceIdx, err := pickNextAction(p)
p.ReportError(err)
if IsSeriousError(err) {
if p.DebugLevel < 1 {
return err
}
continue
}
var msg Message
if isCard {
msg, err = p.EnactCard(cardIdx, choiceIdx)
} else {
msg, err = p.EnactPermanentAction(cardIdx, choiceIdx)
}
p.ReportError(err)
if IsSeriousError(err) {
if p.DebugLevel < 1 {
return err
}
continue
}
display(msg)
wait()
}
// Allow player to review state before continuing simulation.
// Errors from review mode are reported *after* the simulation
// step because the first thing Simulate does is throw out old
// messages -- like these errors.
reviewErr := review(p)
if p.DebugLevel < 1 && IsSeriousError(err) {
return reviewErr
}
err = p.Simulate()
if p.DebugLevel < 1 && IsSeriousError(err) {
return err
}
// Simulation errors are already in messages; now add the review error.
p.ReportError(reviewErr)
if p.DebugLevel < 1 && p.State.Over() {
return nil
}
}
// loop forever until the game ends or the player quits
}
func display(m Message) {
if m == nil {
return
}
fmt.Println(m.String())
}
func wait() {
fmt.Println()
fmt.Println("<press ENTER to continue>")
fmt.Scanln()
}
func displayMainMenu[C StatsCollection](p *Player[C]) (actionsOffset, handOffset, max int) {
cls()
needsDivider := displayMessageSection(p)
if needsDivider {
divider()
}
displayOnePanel(p, p.Prompt)
divider()
actionsOffset = displayStatsMenu(p)
if actionsOffset > 0 {
divider()
}
handOffset = displayPermanentActionsMenu(p, actionsOffset)
if handOffset > actionsOffset {
fmt.Println()
}
max = displayHandMenu(p, handOffset)
return // uses named return values
}
func pickNextAction[C StatsCollection](p *Player[C]) (isCard bool, cardIdx int, choiceIdx int, err error) {
for {
actionsOffset, handOffset, max := displayMainMenu(p)
divider()
fmt.Printf("%d actions remaining.\n", p.ActionsRemaining)
fmt.Printf("Show just (M)essages, (I)nfo Panels, (A)ctions, or consider an item (1-%d), or (Q)uit? > ", max)
input := getResponse()
switch input {
// Special cases
case "m", "msg", "message", "messages":
cls()
if displayMessageSection(p) {
divider()
}
displayOnePanel(p, p.Prompt)
wait()
case "s", "stat", "stats", "i", "info", "p", "panel", "panels", "infopanel", "infopanels":
statsMode(p)
case "a", "act", "actions":
var committed bool
isCard, cardIdx, choiceIdx, committed, err = actionsMode(p, true)
if committed {
return
}
case "q", "quit", "exit":
confirmQuit()
default:
i, err := strconv.Atoi(input)
if err != nil {
fmt.Println("Sorry, I don't understand.")
wait()
} else if i > max {
fmt.Println("That's not on this menu. If the menu is too big to read, choose a detail view.")
wait()
} else if i <= actionsOffset {
cls()
displayOnePanel(p, p.InfoPanels[i-1])
wait()
} else if i <= handOffset {
i = i - actionsOffset - 1
option, promptErr := promptCard(p, p.PermanentActions[i])
if option >= 0 || IsSeriousError(promptErr) {
return false, i, option, promptErr
}
} else {
i = i - handOffset - 1
option, promptErr := promptCard(p, p.Hand[i])
if option >= 0 || IsSeriousError(promptErr) {
return true, i, option, nil
}
}
}
}
}
func cls() {
fmt.Println("\033[H\033[2J")
}
func getResponse() string {
var input string
fmt.Scanln(&input)
input = strings.TrimSpace(input)
input = strings.ToLower(input)
return input
}
func divider() {
fmt.Println()
fmt.Println(ChapterBreak.String())
fmt.Println()
}
func lightDivider() {
fmt.Println()
fmt.Println(SectionBreak.String())
fmt.Println()
}
func confirmQuit() {
divider()
fmt.Println("Are you sure you want to quit? (Y/N) > ")
s := getResponse()
if s == "y" || s == "yes" {
fmt.Println("Bye!")
os.Exit(0)
}
}
func displayOnePanel[C StatsCollection](p *Player[C], panel InfoPanel[C]) error {
ts := panel.Title(p).String()
if len(ts) > 0 {
fmt.Println(ts)
fmt.Println(strings.Repeat("-", len(ts)))
fmt.Println()
}
m, err := panel.Info(p)
if IsSeriousError(err) {
return err
}
display(MultiMessage(m))
return err
}
func displayMessageSection[C StatsCollection](p *Player[C]) bool {
if len(p.TemporaryMessages) == 0 {
return false
}
for _, m := range p.TemporaryMessages {
if m != nil {
display(m)
} else {
fmt.Println()
}
}
return true
}
func displayStatsMenu[C StatsCollection](p *Player[C]) int {
if len(p.InfoPanels) == 0 {
return 0
}
fmt.Println("Info Panels")
fmt.Println("-----------")
for i, s := range p.InfoPanels {
fmt.Printf("[%2d]: %s\n", i+1, s.Title(p).String())
}
return len(p.InfoPanels)
}
func displayPermanentActionsMenu[C StatsCollection](p *Player[C], offset int) int {
if len(p.PermanentActions) == 0 {
return offset
}
fmt.Println("Always Available")
fmt.Println("----------------")
for i, s := range p.PermanentActions {
fmt.Printf("[%2d]: %s\n", i+offset+1, s.Title(p))
}
return offset + len(p.PermanentActions)
}
func displayHandMenu[C StatsCollection](p *Player[C], offset int) int {
if len(p.Hand) == 0 {
return offset
}
fmt.Println("Hand")
fmt.Println("----")
for i, s := range p.Hand {
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
// they chose, or -1 if there was a serious error or they cancelled selection.
func promptCard[C StatsCollection](p *Player[C], card Card[C]) (optionIdx int, err error) {
// Iterate until the player makes a valid choice.
for {
opts, valid, err := displayCard(p, card, true)
if IsSeriousError(err) {
return -1, err
}
fmt.Println()
if valid {
fmt.Printf("Go (B)ack, (Q)uit, or enact a choice (1 - %d)? > ", len(opts))
} else {
fmt.Print("Go (B)ack or (Q)uit? > ")
}
read := getResponse()
switch read {
case "b", "back":
return -1, err
case "q", "quit":
confirmQuit()
default:
i, convErr := strconv.Atoi(read)
if convErr != nil {
fmt.Println("Sorry, I don't understand.")
wait()
} else if !valid {
fmt.Println("You can't enact anything here.")
wait()
} else if i <= 0 || i > len(opts) {
fmt.Println("That's not one of the options.")
wait()
} else if !opts[i-1].Enabled(p) {
fmt.Println("That option is not available to you right now.")
wait()
} else {
return i - 1, err
}
}
// Invalid selection made -- loop to prompt again.
}
}
func displayCard[C StatsCollection](p *Player[C], card Card[C], canAct bool) ([]CardOption[C], bool, error) {
cls()
t := card.Title(p).String()
urgent := card.Urgent(p)
if urgent {
t = "[URGENT!] " + t
}
fmt.Println(t)
fmt.Println(strings.Repeat("-", len(t)))
fmt.Println()
event, err := card.EventText(p)
if IsSeriousError(err) {
return nil, false, err
}
var errs ErrorCollector
errs.Add(err)
fmt.Println(event.String())
fmt.Println()
fmt.Println(SectionBreak.String())
fmt.Println()
if !urgent && p.HasUrgentCards() {
fmt.Println("<You have more urgent matters to attend to! You cannot act on this right now.>")
fmt.Println()
canAct = false
}
opts, optErr := card.Options(p)
errs.Add(optErr)
if IsSeriousError(optErr) {
return nil, false, errs.Emit()
}
valid := false
for i, opt := range opts {
pfx := "[xx]:"
if opt.Enabled(p) {
if canAct {
pfx = fmt.Sprintf("[%2d]:", i+1)
valid = true
} else {
pfx = "[--]:"
}
}
t, err := opt.OptionText(p)
errs.Add(err)
if IsSeriousError(err) {
return nil, false, errs.Emit()
}
fmt.Println(pfx, t.String())
}
return opts, valid, errs.Emit()
}
func statsMode[C StatsCollection](p *Player[C]) error {
var errs ErrorCollector
for {
cls()
n := displayStatsMenu(p)
if n <= 0 {
fmt.Println("No info panels are available.")
wait()
return errs.Emit()
}
fmt.Println()
fmt.Printf("Go (B)ack, (Q)uit, or view an info panel (1-%d)? > ", n)
s := getResponse()
switch s {
case "b", "back":
return errs.Emit()
case "q", "quit":
confirmQuit()
default:
v, err := strconv.Atoi(s)
if err != nil {
fmt.Println("Sorry, I don't understand.")
wait()
} else if v <= 0 || v > n {
fmt.Println("There's no info panel with that index.")
} else {
cls()
err := displayOnePanel(p, p.InfoPanels[v-1])
errs.Add(err)
if IsSeriousError(err) {
return errs.Emit()
}
wait()
}
}
// Loop to re-display info panels menu.
}
}
func actionsMode[C StatsCollection](p *Player[C], canAct bool) (isCard bool, cardIdx, choiceIdx int, committed bool, err error) {
var errs ErrorCollector
for {
cls()
pOff := displayPermanentActionsMenu(p, 0)
if pOff > 0 {
fmt.Println()
}
max := displayHandMenu(p, pOff)
if max <= 0 {
fmt.Println("There are no actions available and no cards in hand.")
fmt.Println("That's a problem. The game is stuck.")
confirmQuit()
errs.Add(WarningStalemate)
return false, -1, -1, true, errs.Emit()
}
fmt.Println()
if canAct {
fmt.Printf("Go (B)ack, (Q)uit, or consider an action (1-%d)? > ", max)
} else {
fmt.Printf("Go (B)ack, (Q)uit, or view an action (1-%d)? > ", max)
}
input := getResponse()
switch input {
case "b", "back":
return false, -1, -1, false, errs.Emit()
case "q", "quit":
confirmQuit()
default:
v, err := strconv.Atoi(input)
if err != nil {
fmt.Println("Sorry, I don't understand.")
wait()
} else if v < 1 || v > max {
fmt.Println("That's not a card or action.")
wait()
} else if v <= pOff {
v--
if canAct {
optIdx, err := promptCard(p, p.PermanentActions[v])
errs.Add(err)
if optIdx >= 0 || IsSeriousError(err) {
return false, v, optIdx, true, errs.Emit()
}
} else {
_, _, err := displayCard(p, p.PermanentActions[v], false)
errs.Add(err)
if IsSeriousError(err) {
return false, -1, -1, true, errs.Emit()
}
wait()
}
} else {
v = v - pOff - 1
if canAct {
optIdx, err := promptCard(p, p.Hand[v])
errs.Add(err)
if optIdx >= 0 || IsSeriousError(err) {
return true, v, optIdx, false, errs.Emit()
}
} else {
_, _, err := displayCard(p, p.Hand[v], false)
errs.Add(err)
if IsSeriousError(err) {
return false, -1, -1, false, errs.Emit()
}
wait()
}
}
}
// Re-prompt to get a valid choice.
}
}
func review[C StatsCollection](p *Player[C]) error {
var errs ErrorCollector
for {
actionsOffset, handOffset, max := displayMainMenu(p)
divider()
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)
input := getResponse()
switch input {
// Special cases
case "m", "msg", "message", "messages":
cls()
if displayMessageSection(p) {
divider()
}
displayOnePanel(p, p.Prompt)
wait()
case "s", "stat", "stats", "i", "info", "p", "panel", "panels", "infopanel", "infopanels":
err := statsMode(p)
errs.Add(err)
if IsSeriousError(err) {
return errs.Emit()
}
case "a", "act", "actions":
_, _, _, _, err := actionsMode(p, false)
errs.Add(err)
if IsSeriousError(err) {
return errs.Emit()
}
case "q", "quit", "exit":
confirmQuit()
case "c", "continue", "ok":
return errs.Emit()
default:
i, err := strconv.Atoi(input)
if err != nil {
fmt.Println("Sorry, I don't understand.")
wait()
} 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.")
wait()
} else if i <= actionsOffset {
cls()
displayOnePanel(p, p.InfoPanels[i-1])
wait()
} else if i <= handOffset {
i = i - actionsOffset - 1
_, _, err := displayCard(p, p.PermanentActions[i], false)
errs.Add(err)
if IsSeriousError(err) {
return errs.Emit()
}
wait()
} else {
i = i - handOffset - 1
_, _, err := displayCard(p, p.Hand[i], false)
errs.Add(err)
if IsSeriousError(err) {
return errs.Emit()
}
wait()
}
}
}
}

10
go.mod
View File

@ -1,3 +1,11 @@
module cardSimEngine module git.chromaticdragon.app/kistaro/CardSimEngine
go 1.20 go 1.20
require github.com/kr/pretty v0.3.1
require (
github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230321023759-10a507213a29
)

10
go.sum Normal file
View File

@ -0,0 +1,10 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
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=

163
smoketest/cards.go Normal file
View File

@ -0,0 +1,163 @@
package main
import (
"errors"
"fmt"
"git.chromaticdragon.app/kistaro/CardSimEngine/cardsim"
)
// Type aliases, unlike distinctly named types, are fully substitutable for
// the original type. This trims off some annoying-to-type things.
type player = cardsim.Player[*SmokeTestCollection]
type card = cardsim.Card[*SmokeTestCollection]
type cardOption = cardsim.CardOption[*SmokeTestCollection]
func makeAdditionCard(amt int) cardsim.Card[*SmokeTestCollection] {
c := &cardsim.BasicCard[*SmokeTestCollection]{
CardTitle: cardsim.Msgf("Additive %d", amt),
CardText: cardsim.Msgf("You can change the Number by %d.", amt),
CardOptions: []cardsim.CardOption[*SmokeTestCollection]{
&cardsim.BasicOption[*SmokeTestCollection]{
Text: cardsim.Msgf("Add %d", amt),
Effect: func(p *player) error {
p.Stats.Number.Value += amt
return nil
},
Output: cardsim.MsgStr("Added."),
},
&cardsim.BasicOption[*SmokeTestCollection]{
Text: cardsim.Msgf("Subtract %d", amt),
Effect: func(p *player) error {
p.Stats.Number.Value -= amt
return nil
},
Output: cardsim.MsgStr("Subtracted."),
},
},
AfterOption: func(c card, p *player, _ cardOption) error {
p.Deck.InsertRandomBottom(0.5, c)
return nil
},
}
return c
}
func makeMultiplicationCard(amt int) cardsim.Card[*SmokeTestCollection] {
c := &cardsim.BasicCard[*SmokeTestCollection]{
CardTitle: cardsim.Msgf("Multiplicative %d", amt),
CardText: cardsim.Msgf("You can multiply or divide the Number by %d, or maybe divide the Number by that.", amt),
CardOptions: []cardsim.CardOption[*SmokeTestCollection]{
&cardsim.BasicOption[*SmokeTestCollection]{
Text: cardsim.Msgf("Multiply by %d", amt),
Effect: func(p *player) error {
p.Stats.Number.Value *= amt
return nil
},
Output: cardsim.MsgStr("Multiplied."),
},
&cardsim.BasicOption[*SmokeTestCollection]{
Text: cardsim.Msgf("Integer divide by %d", amt),
Effect: func(p *player) error {
p.Stats.Number.Value /= amt
return nil
},
Output: cardsim.MsgStr("Divided."),
},
inverseDivision(amt),
},
AfterOption: func(c card, p *player, _ cardOption) error {
p.Deck.InsertRandomBottom(0.5, c)
return nil
},
}
return c
}
type inverseDivision int
func (i inverseDivision) OptionText(p *player) (cardsim.Message, error) {
if p.Stats.Number.Value == 0 {
return cardsim.MsgStr("You can't divide by zero!"), nil
}
return cardsim.Msgf("Divide %d by the Number", int(i)), nil
}
func (i inverseDivision) Enact(p *player) (cardsim.Message, error) {
if p.Stats.Number.Value == 0 {
return nil, errors.New("you can't divide by zero!")
}
p.Stats.Number.Value = int(i) / p.Stats.Number.Value
return cardsim.MsgStr("Inverse divided."), nil
}
func (i inverseDivision) Enabled(p *player) bool {
return p.Stats.Number.Value != 0
}
func initDeck(d *cardsim.Deck[*SmokeTestCollection]) {
addMe := []int{
0, 1, 2, 5, 10, 50, 100, 1000, 2500, 500000, 9876543,
}
for _, n := range addMe {
d.Insert(cardsim.BottomOfDeck, makeAdditionCard(n))
}
multiplyMe := []int{
2, 4, 8, 16, 32, 64, 128, 512, 1024, 9999, 84720413,
}
for _, n := range multiplyMe {
d.Insert(cardsim.BottomOfDeck, makeMultiplicationCard(n))
}
if err := d.Shuffle(); cardsim.IsSeriousError(err) {
panic(err)
} else if err != nil {
fmt.Printf("Error shuffling: %v\n", err)
}
}
func installPermanentActions(pa *[]card) {
*pa = []card{
&cardsim.BasicCard[*SmokeTestCollection]{
CardTitle: cardsim.MsgStr("Reset to 0"),
CardText: cardsim.MsgStr("Resets Number to 0."),
CardOptions: []cardOption{
&cardsim.BasicOption[*SmokeTestCollection]{
Text: cardsim.MsgStr("Reset to 0."),
Effect: func(p *player) error {
p.Stats.Number.Value = 0
return nil
},
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]{
Text: cardsim.MsgStr("Reset to 1,000,000"),
Effect: func(p *player) error {
p.Stats.Number.Value = 1000000
return nil
},
Output: cardsim.MsgStr("Done."),
},
},
},
&cardsim.BasicCard[*SmokeTestCollection]{
CardTitle: cardsim.MsgStr("Draw a card"),
CardText: cardsim.MsgStr("Draw an extra card."),
CardOptions: []cardOption{
&cardsim.BasicOption[*SmokeTestCollection]{
Text: cardsim.MsgStr("Draw an extra card."),
Effect: func(p *player) error {
return p.Draw()
},
Output: cardsim.MsgStr("Drawn. Probably."),
},
},
},
}
}

34
smoketest/collection.go Normal file
View File

@ -0,0 +1,34 @@
package main
import (
"git.chromaticdragon.app/kistaro/CardSimEngine/cardsim"
)
// SmokeTestCollection is a stats collection for the simple test sim.
type SmokeTestCollection struct {
Number cardsim.Stored[int]
Total cardsim.Stored[int64]
Turns cardsim.Invisible[int]
Flavor cardsim.Stored[string]
Things int `cardsim:"stat"`
MoreThings int `cardsim:"hidden"`
FloatyThings float64 `cardsim:"round1"`
Label string `cardsim:"stat"`
}
func (c *SmokeTestCollection) Average() float64 {
return float64(c.Total.Value) / float64(c.Turns.Value)
}
func (c *SmokeTestCollection) Stats() []cardsim.Stat {
stats := cardsim.ExtractStats(c)
stats = append(stats, cardsim.StatFunc("Average", c.Average))
cardsim.SortStats(stats)
return stats
}
func (c *SmokeTestCollection) StatTotalThings() float64 {
return float64(c.Things+c.MoreThings) + c.FloatyThings
}

83
smoketest/main.go Normal file
View File

@ -0,0 +1,83 @@
// Binary smoketest runs a very simple cardsim thing.
package main
import (
"fmt"
"git.chromaticdragon.app/kistaro/CardSimEngine/cardsim"
"github.com/kr/pretty"
)
func main() {
p := cardsim.InitPlayer(
&SmokeTestCollection{
Number: cardsim.Stored[int]{
Name: "Number",
Value: 0,
},
Total: cardsim.Stored[int64]{
Name: "Total",
Value: 0,
},
Turns: cardsim.Invisible[int]{
Name: "Turns",
Value: 0,
},
Flavor: cardsim.Stored[string]{
Name: "Flavor",
Value: "Lemon",
},
Things: 5,
MoreThings: 9,
FloatyThings: 123.456,
Label: "whee",
},
)
p.Name = "Dave"
p.HandLimit = 3
p.ActionsPerTurn = 2
installRules(p.Rules)
initDeck(p.Deck)
installPermanentActions(&p.PermanentActions)
p.InfoPanels = []cardsim.InfoPanel[*SmokeTestCollection]{
&cardsim.BasicStatsPanel[*SmokeTestCollection]{
Name: cardsim.MsgStr("Stats"),
Intro: cardsim.MsgStr("Hi! These are the smoke test stats."),
},
ruledumper{},
}
p.Prompt = prompt{}
p.DebugLevel = 5
err := cardsim.RunSimpleTerminalUI(p)
if err != nil {
fmt.Println("Terminated with error:")
fmt.Println(err)
} else {
fmt.Println("Terminated without error.")
}
}
type prompt struct{}
func (prompt) Title(p *cardsim.Player[*SmokeTestCollection]) cardsim.Message {
return cardsim.MsgStr("Smoke Test")
}
func (prompt) Info(p *cardsim.Player[*SmokeTestCollection]) ([]cardsim.Message, error) {
return []cardsim.Message{
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),
}, nil
}
type ruledumper struct{}
func (ruledumper) Title(p *player) cardsim.Message {
return cardsim.MsgStr("Rule Dumper")
}
func (ruledumper) Info(p *player) ([]cardsim.Message, error) {
return []cardsim.Message{cardsim.Msgf("%# v", pretty.Formatter(p.Rules))}, nil
}

32
smoketest/rules.go Normal file
View File

@ -0,0 +1,32 @@
package main
import (
"math"
"git.chromaticdragon.app/kistaro/CardSimEngine/cardsim"
)
var (
updateTotal = cardsim.RuleFunc[*SmokeTestCollection]{
Name: "updateTotal",
Seq: 1,
F: func(p *cardsim.Player[*SmokeTestCollection]) error {
p.Stats.Total.Value += int64(p.Stats.Number.Value)
return nil
},
}
countTurn = cardsim.RuleFunc[*SmokeTestCollection]{
Name: "countTurn",
Seq: math.MinInt,
F: func(p *cardsim.Player[*SmokeTestCollection]) error {
p.Stats.Turns.Value++
return nil
},
}
)
func installRules(rules *cardsim.RuleCollection[*SmokeTestCollection]) {
rules.Insert(&updateTotal)
rules.Insert(&countTurn)
}