61 Commits

Author SHA1 Message Date
c30aca1f31 Better error management.
* "Uncooperative cards" is now a warning.
* Cards and actions get "Then" invoked before the card processor considers erroring out.
* Terminal UI: Errors and warnings from actions are displayed during the response; they're not only added to the temporary messages now.
2023-04-15 20:59:21 -07:00
abb00e30c3 Debug action to adjust debug level. 2023-04-15 20:16:54 -07:00
65c01318f0 Fix UI urgency handling.
Only cards can block other cards or actions from being used due to urgency, so only display the `[URGENT!]` header for cards. Debug actions can't be blocked, so skip the "urgency conflict" check for those.
2023-04-15 19:43:11 -07:00
2b788f517c Add action counter spoofer. 2023-04-15 19:36:53 -07:00
8d1aa0141f Don't stomp on the default debugers in SmokeTest. 2023-04-15 19:17:34 -07:00
74fac625f2 Implement standard debuggers.
These debug actions are added to all players by default.
2023-04-15 19:16:08 -07:00
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
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
19 changed files with 2621 additions and 65 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.
@ -40,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
@ -55,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

@ -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,117 @@ 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
}
// RefundAction returns a func that can be used as an AfterOption, which returns
// the player's action point.
func RefundAction[C StatsCollection]() func(c Card[C], p *Player[C], option CardOption[C]) error {
return func(c Card[C], p *Player[C], option CardOption[C]) error {
p.ActionsRemaining++
return nil
}
}
// A PanelCard is a Card that takes its title and text from an InfoPanel,
// while options, urgency, and the post-option callback are specified
// (like a BasicCard). It never does anything in particular when drawn.
//
// Omitting all options yields an inactionable card, which can be displayed
// but not played. This can be useful for adding an info panel as a debug action.
type PanelCard[C StatsCollection] struct {
Panel InfoPanel[C]
IsUrgent bool
CardOptions []CardOption[C]
// AfterOption is given the card itself as its first argument.
AfterOption func(c Card[C], p *Player[C], option CardOption[C]) error
}
// Title implements Card.
func (c *PanelCard[C]) Title(p *Player[C]) Message {
return c.Panel.Title(p)
}
// Urgent implements Card.
func (c *PanelCard[C]) Urgent(_ *Player[C]) bool {
return c.IsUrgent
}
// EventText implements Card.
func (c *PanelCard[C]) EventText(p *Player[C]) (Message, error) {
msgs, err := c.Panel.Info(p)
return MultiMessage(msgs), err
}
// Options implements Card.
func (c *PanelCard[C]) Options(_ *Player[C]) ([]CardOption[C], error) {
return c.CardOptions, nil
}
// Then implements Card.
func (c *PanelCard[C]) Then(p *Player[C], option CardOption[C]) error {
if c.AfterOption == nil {
return nil
}
return c.AfterOption(c, p, option)
}
// Drawn implements Card.
func (c *PanelCard[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 +176,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 +194,31 @@ 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
}
// OnlyDiscardFree returns a []CardOption[C] providing a single option, which
// returns the action point. It does not shuffle the card back into the deck
// or draw a replacement (consider the AfterFunc for that if needed). This
// can be used for cards that are displayable but not actionable, but show up
// as cards rather than permanent or debug actions for some reason.
func OnlyDiscardFree[C StatsCollection](msg Message) []CardOption[C] {
return []CardOption[C]{
OptionFunc(msg, func(p *Player[C]) (Message, error) {
p.ActionsRemaining++
return MsgStr("Okay."), nil
}),
}
}

103
cardsim/debugging.go Normal file
View File

@ -0,0 +1,103 @@
package cardsim
// Named debug verbosity levels. Using the raw constants is fine too. This
// is roughly consistent with "standard" meanings for these debug levels.
const (
HideWarnings = -1
NotDebugging = 0
DebugWarning = 1
DebugInfo = 2
DebugDetail = 3
DebugFine = 4
DebugSuperfine = 5
)
// ActionCounterDebugCard constructs a BasicCard intended for use only as a
// Debug Action that tinkers with the player's action counter.
func ActionCounterDebugCard[C StatsCollection]() Card[C] {
return &BasicCard[C]{
CardTitle: MsgStr("Adjust Action Counter"),
CardText: MsgStr("Change the number of actions you have available this turn."),
CardOptions: []CardOption[C]{
&BasicOption[C]{
Text: MsgStr("Get an extra action."),
Effect: func(p *Player[C]) error {
p.ActionsRemaining += 2 // counteract the one this costs
return nil
},
Output: MsgStr("Gotten."),
},
&BasicOption[C]{
Text: MsgStr("Waste an action."),
Effect: func(p *Player[C]) error {
return nil
},
Output: MsgStr("Wasted."),
},
&BasicOption[C]{
Text: MsgStr("Get a thousand actions."),
Effect: func(p *Player[C]) error {
p.ActionsRemaining = 1000
return nil
},
Output: MsgStr("ActionsRemaining set to 1000."),
},
&BasicOption[C]{
Text: MsgStr("Go to exactly 1 action remaining."),
Effect: func(p *Player[C]) error {
p.ActionsRemaining = 1
return nil
},
Output: MsgStr("ActionsRemaining set to 1."),
},
&BasicOption[C]{
Text: MsgStr("End the turn. (Set actions to 0.)"),
Effect: func(p *Player[C]) error {
p.ActionsRemaining = 0
return nil
},
Output: MsgStr("ActionsRemaining zeroed out."),
},
},
}
}
// DebugModeCard constructs a BasicCard to change the player's debug level.
// It is intended for use only as a Debug Action.
func DebugModeCard[C StatsCollection]() Card[C] {
return &BasicCard[C]{
CardTitle: MsgStr("Change Debug Level"),
CardText: MsgStr("Adjust verbosity of output, or exit debug mode entirely (not recommended)."),
CardOptions: []CardOption[C]{
debugLevelOption[C]{1, "Enable debug mode. Show warnings."},
debugLevelOption[C]{2, "Enable debug mode. Show info messages."},
debugLevelOption[C]{3, "Enable debug mode. Show detailed messages."},
debugLevelOption[C]{4, "Enable debug mode. Show individual details of operations."},
debugLevelOption[C]{5, "Enable debug mode. Show every event in excruciating detail."},
debugLevelOption[C]{0, "NOT RECOMMENDED. Disable debugging (show warnings). IT CAN'T BE TURNED BACK ON."},
debugLevelOption[C]{-1, "NOT RECOMMENDED. Disable debugging (hide warnings). IT CAN'T BE TURNED BACK ON."},
},
AfterOption: RefundAction[C](),
}
}
type debugLevelOption[C StatsCollection] struct {
level int
description string
}
// OptionText implements CardOption[C].
func (d debugLevelOption[C]) OptionText(*Player[C]) (Message, error) {
return Msgf("Set debug level %d: %s", d.level, d.description), nil
}
// Enact implements CardOption[C].
func (d debugLevelOption[C]) Enact(p *Player[C]) (Message, error) {
p.DebugLevel = d.level
return Msgf("Debug level is now %d.", d.level), nil
}
// Enabled implements CardOption[C].
func (d debugLevelOption[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,228 @@ 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()
}
// DeckDebugger is a Card[C], intended for use only as a debug action, that
// lists the top 10 cards of the deck (without checking if they are drawable)
// and allows various sorts of deck manipulation for free. It can't be drawn.
type DeckDebugger[C StatsCollection] struct{}
// Title implements Card[C].
func (DeckDebugger[C]) Title(p *Player[C]) Message {
return MsgStr("Debug Mode Deck Controls")
}
// Urgent implements Card[C] as used in permanent actions. It's always valid
// to use the deck debugger. Debug actions do not check urgency flags, but this
// marks itself as urgent-compatible just in case.
func (DeckDebugger[C]) Urgent(p *Player[C]) bool {
return true
}
// Drawn implements Card[C]. It can't be drawn.
func (DeckDebugger[C]) Drawn(p *Player[C]) bool {
return false
}
// EventText implements Card[C]. It lists the top ten cards of the deck and
// a few deck-related and hand-related stats.
func (DeckDebugger[C]) EventText(p *Player[C]) (Message, error) {
var msgs []Message
msgs = append(msgs, Msgf("The Deck contains %d cards.", p.Deck.Len()))
if p.Deck.Len() > 0 {
portion := p.Deck.cards
msgs = append(msgs, nil)
topness := "All"
if p.Deck.Len() > 10 {
portion = p.Deck.cards[:10]
topness = "Top 10"
}
msgs = append(msgs, Msgf("%s cards in the Deck:", topness))
for i, c := range portion {
urgency := " "
if c.Urgent(p) {
urgency = "!"
}
msgs = append(msgs, Msgf(" %s %2d) %v", urgency, i+1, c.Title(p)))
}
}
msgs = append(msgs, nil)
msgs = append(msgs, Msgf("At the start of each turn, the Player draws to %d cards. The player has %d cards in hand.", p.HandLimit, len(p.Hand)))
return MultiMessage(msgs), nil
}
// Options implements Card[C]. It offers many possible actions.
func (DeckDebugger[C]) Options(p *Player[C]) ([]CardOption[C], error) {
ret := []CardOption[C]{
&BasicOption[C]{
Text: MsgStr("Draw a card."),
Effect: func(p *Player[C]) error {
return p.Draw()
},
Output: MsgStr("Done."),
},
&BasicOption[C]{
Text: MsgStr("Shuffle the deck."),
Effect: func(p *Player[C]) error {
return p.Deck.Shuffle()
},
Output: MsgStr("Done."),
},
&BasicOption[C]{
Text: MsgStr("Shuffle top half."),
Effect: func(p *Player[C]) error {
return p.Deck.ShuffleTop(0.5)
},
Output: MsgStr("Done."),
},
&BasicOption[C]{
Text: MsgStr("Shuffle bottom half."),
Effect: func(p *Player[C]) error {
return p.Deck.ShuffleBottom(0.5)
},
Output: MsgStr("Done."),
},
}
for _, n := range []int{1, 3, 5, 10} {
if p.Deck.Len() <= n {
break
}
// We don't want the functions we're creating to all share the same "n"
// field -- we want to create distinct functions that move distinct
// numbers of cards. For more information on what's going on here, see
// https://eli.thegreenplace.net/2019/go-internals-capturing-loop-variables-in-closures/
//
// For the curious, the Go developers don't like the gotcha that
// this "shadow variable with itself" workaround patches over either.
// Here's the guy who implemented apologizing for it and discussing
// changing it: https://github.com/golang/go/discussions/56010
//
// "Loop variables being per-loop instead of per-iteration is the only
// design decision I know of in Go that makes programs incorrect more
// often than it makes them correct." -- Russ Cox (rsc)
n := n
invN := p.Deck.Len() - n
ret = append(ret,
&BasicOption[C]{
Text: Msgf("Move the top %d card(s) to the bottom of the deck, in order.", n),
Effect: func(p *Player[C]) error {
p.Deck.cards = append(p.Deck.cards, p.Deck.cards[:n]...)
p.Deck.cards = p.Deck.cards[n:]
return nil
},
Output: MsgStr("Done."),
},
&BasicOption[C]{
Text: Msgf("Move the bottom %d card(s) to the top of the deck, in order.", n),
Effect: func(p *Player[C]) error {
p.Deck.cards = append(p.Deck.cards, p.Deck.cards[:invN]...)
p.Deck.cards = p.Deck.cards[invN:]
return nil
},
Output: MsgStr("Done."),
},
)
if n > 1 {
ret = append(ret, &BasicOption[C]{
Text: Msgf("Shuffle the top %d card(s) of the deck.", n),
Effect: func(p *Player[C]) error {
return p.Deck.ShufflePart(0, n)
},
Output: MsgStr("Done."),
})
}
}
return ret, nil
}
// Then implements Card[C]. It refunds the action point.
func (DeckDebugger[C]) Then(p *Player[C], o CardOption[C]) error {
p.ActionsRemaining++
return nil
}

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
@ -9,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 {
@ -25,3 +34,63 @@ 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...))
} }
// ErrorMessage returns a Message representing an Error.
// This is preferred over Msgf for errors, since future versions of the library
// may perform special message formatting for errors.
func ErrorMessage(e error) Message {
if e == nil {
return nil
}
if IsSeriousError(e) {
return MultiMessage{MsgStr("SERIOUS ERROR:"), Msgf("%v", e)}
}
return MultiMessage{MsgStr("Warning:"), Msgf("%v", e)}
}
// 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,536 @@
package cardsim package cardsim
import "math/rand" import (
"errors"
"fmt"
"math/rand"
"time"
)
// Player stores all gameplay state for one player. var (
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")
ErrNotDebugging = errors.New("this is a debug-only feature and you're not in debug mode")
WarningStalemate = &Warning{errors.New("no actions can be taken")}
WarningUncoperativeCards = &Warning{errors.New("a milion cards refused to join the hand")}
)
// 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]
// DebugActions are PermanentActions only available when the player is in
// debug mode. InitPlayer adds some standard debugging actions by default.
DebugActions []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](),
DebugActions: []Card[C]{
&DeckDebugger[C]{},
&PanelCard[C]{Panel: RuleDumper[C]{}},
ActionCounterDebugCard[C](),
DebugModeCard[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 gives up and
// returns WarningUncooperativeCards. 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 WarningUncoperativeCards
}
// 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 gives up
// and returns WarningUncooperativeCards. 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
}
// 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
// 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)
err = card.Then(p, options[choiceIdx])
errs.Add(err)
err = errs.Emit()
if IsSeriousError(err) {
p.State = GameCrashed
}
return ret, err
}
// 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) {
return p.enactActionUnchecked(p.PermanentActions, actionIdx, choiceIdx)
}
// 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
}
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
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 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 action #%d is not enabled", ErrInvalidChoice, choiceIdx, actionIdx))
return nil, errs.Emit()
}
p.ActionsRemaining--
ret, err := chosen.Enact(p)
errs.Add(err)
err = card.Then(p, chosen)
errs.Add(err)
retErr := errs.Emit()
if IsSeriousError(retErr) {
p.State = GameCrashed
}
return ret, retErr
}
// 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
}
minLvl := NotDebugging
if IsSeriousError(e) {
minLvl = HideWarnings
}
p.Debug(minLvl, ErrorMessage(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.ChapterBreak()
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

@ -4,9 +4,32 @@ import (
"errors" "errors"
"fmt" "fmt"
"sort" "sort"
"github.com/kr/pretty"
) )
// 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 +143,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 +328,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 +336,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 +382,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() {
@ -374,3 +404,14 @@ func (r *RuleCollection[C]) applyDelayedUpdates() {
r.RemoveID(id) r.RemoveID(id)
} }
} }
// RuleDumper is an InfoPanel[C] that dumps all rules in P.
type RuleDumper[C StatsCollection] struct{}
func (RuleDumper[C]) Title(p *Player[C]) Message {
return MsgStr("Rule Dumper")
}
func (RuleDumper[C]) Info(p *Player[C]) ([]Message, error) {
return []Message{Msgf("%# v", pretty.Formatter(p.Rules))}, nil
}

165
cardsim/sliceutil.go Normal file
View File

@ -0,0 +1,165 @@
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]
}
// EnsureCapacity checks if `cap(slice)` is at least req. If so, it returns
// slice unchanged. Otherwise, it copies `slice` to a new slice that is at least
// capacity `req` (but may be larger) and returns the copy.
//
// It is reasonably efficient to use EnsureCapacity consecutively without
// regard for the final overall capacity that a specific slice will need to be.
func EnsureCapacity[T any](slice []T, req int) []T {
if cap(slice) >= req {
return slice
}
if req < 2*cap(slice) {
req = 2 * cap(slice)
}
ret := make([]T, len(slice), req)
copy(ret, slice)
return ret
}

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,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() {
@ -123,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
} }
@ -171,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
}

582
cardsim/terminalui.go Normal file
View File

@ -0,0 +1,582 @@
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() {
actionType, cardIdx, choiceIdx, err := pickNextAction(p)
p.ReportError(err)
if IsSeriousError(err) {
if p.DebugLevel < 1 {
return err
}
continue
}
var msg Message
switch actionType {
case CardEnactable:
msg, err = p.EnactCard(cardIdx, choiceIdx)
case DebugActionEnactable:
msg, err = p.EnactDebugActionUnchecked(cardIdx, choiceIdx)
case PermanentActionEnactable:
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)
if IsSeriousError(err) {
if p.DebugLevel < 1 {
return err
}
continue
}
if err != nil {
display(ErrorMessage(err))
display(MsgStr(""))
}
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]) (debugOffset, actionsOffset, handOffset, max int) {
cls()
needsDivider := displayMessageSection(p)
if needsDivider {
divider()
}
displayOnePanel(p, p.Prompt)
divider()
debugOffset = displayStatsMenu(p)
if debugOffset > 0 {
divider()
}
actionsOffset = displayDebugActionsMenu(p, debugOffset)
if actionsOffset > debugOffset {
fmt.Println()
}
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]) (actionType EnactableType, cardIdx int, choiceIdx int, err error) {
for {
debugOffset, 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":
actionType, cardIdx, choiceIdx, err = actionsMode(p, true)
if actionType != NothingEnactable {
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 <= debugOffset {
cls()
displayOnePanel(p, p.InfoPanels[i-1])
wait()
} else if i <= actionsOffset {
i = i - debugOffset - 1
option, promptErr := promptCard(p, p.DebugActions[i], DebugActionEnactable)
if option >= 0 || IsSeriousError(promptErr) {
return DebugActionEnactable, i, option, promptErr
}
} else if i <= handOffset {
i = i - actionsOffset - 1
option, promptErr := promptCard(p, p.PermanentActions[i], PermanentActionEnactable)
if option >= 0 || IsSeriousError(promptErr) {
return PermanentActionEnactable, i, option, promptErr
}
} else {
i = i - handOffset - 1
option, promptErr := promptCard(p, p.Hand[i], CardEnactable)
if option >= 0 || IsSeriousError(promptErr) {
return CardEnactable, 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("-----------")
displayNumberedTitles(p, p.InfoPanels, 0)
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("----------------")
displayNumberedTitles(p, p.PermanentActions, offset)
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 {
if len(p.Hand) == 0 {
return offset
}
fmt.Println("Hand")
fmt.Println("----")
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))
}
}
// 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], cardType EnactableType) (optionIdx int, err error) {
// Iterate until the player makes a valid choice.
for {
opts, valid, err := displayCard(p, card, cardType, 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], cardType EnactableType, canAct bool) ([]CardOption[C], bool, error) {
cls()
t := card.Title(p).String()
urgent := card.Urgent(p)
if urgent && cardType == CardEnactable {
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 && cardType != DebugActionEnactable && 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) (actionType EnactableType, cardIdx, choiceIdx int, err error) {
var errs ErrorCollector
for {
cls()
dOff := displayDebugActionsMenu(p, 0)
if dOff > 0 {
fmt.Println()
}
pOff := displayPermanentActionsMenu(p, dOff)
if pOff > dOff {
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 NothingEnactable, -1, -1, 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 NothingEnactable, -1, -1, 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 <= dOff {
v--
if canAct {
optIdx, err := promptCard(p, p.DebugActions[v], DebugActionEnactable)
errs.Add(err)
if optIdx >= 0 || IsSeriousError(err) {
return DebugActionEnactable, v, optIdx, errs.Emit()
}
} else {
_, _, err := displayCard(p, p.DebugActions[v], DebugActionEnactable, false)
errs.Add(err)
if IsSeriousError(err) {
return DebugActionEnactable, -1, -1, errs.Emit()
}
wait()
}
} else if v <= pOff {
v = v - dOff - 1
if canAct {
optIdx, err := promptCard(p, p.PermanentActions[v], PermanentActionEnactable)
errs.Add(err)
if optIdx >= 0 || IsSeriousError(err) {
return PermanentActionEnactable, v, optIdx, errs.Emit()
}
} else {
_, _, err := displayCard(p, p.PermanentActions[v], PermanentActionEnactable, false)
errs.Add(err)
if IsSeriousError(err) {
return PermanentActionEnactable, -1, -1, errs.Emit()
}
wait()
}
} else {
v = v - pOff - 1
if canAct {
optIdx, err := promptCard(p, p.Hand[v], CardEnactable)
errs.Add(err)
if optIdx >= 0 || IsSeriousError(err) {
return CardEnactable, v, optIdx, errs.Emit()
}
} else {
_, _, err := displayCard(p, p.Hand[v], CardEnactable, false)
errs.Add(err)
if IsSeriousError(err) {
return CardEnactable, -1, -1, errs.Emit()
}
wait()
}
}
}
// Re-prompt to get a valid choice.
}
}
func review[C StatsCollection](p *Player[C]) error {
var errs ErrorCollector
for {
debugOffset, 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.")
} 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.")
} else if i <= debugOffset {
cls()
displayOnePanel(p, p.InfoPanels[i-1])
} else if i <= actionsOffset {
i = i - debugOffset - 1
_, _, err := displayCard(p, p.DebugActions[i], DebugActionEnactable, false)
errs.Add(err)
if IsSeriousError(err) {
return errs.Emit()
}
} else if i <= handOffset {
i = i - actionsOffset - 1
_, _, err := displayCard(p, p.PermanentActions[i], PermanentActionEnactable, false)
errs.Add(err)
if IsSeriousError(err) {
return errs.Emit()
}
} else {
i = i - handOffset - 1
_, _, err := displayCard(p, p.Hand[i], CardEnactable, 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=

144
smoketest/cards.go Normal file
View File

@ -0,0 +1,144 @@
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 Number"),
CardText: cardsim.MsgStr("Resets Number to a fixed value."),
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.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."),
},
},
},
}
}

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" cardsim_name:"A Renamed Thing"`
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
}

71
smoketest/main.go Normal file
View File

@ -0,0 +1,71 @@
// Binary smoketest runs a very simple cardsim thing.
package main
import (
"fmt"
"git.chromaticdragon.app/kistaro/CardSimEngine/cardsim"
)
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."),
},
}
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),
cardsim.Msgf("The current Number is %d. It tastes like %s.", p.Stats.Number.Value, p.Stats.Flavor.Value),
}, 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)
}