aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino.go19
-rw-r--r--internal/game/game.go8
-rw-r--r--internal/game/render_roulette.go27
-rw-r--r--internal/game/render_roulette_test.go5
-rw-r--r--internal/game/render_video_poker.go14
-rw-r--r--internal/game/roulette_animation_test.go102
-rw-r--r--internal/game/video_poker_alignment_test.go35
-rw-r--r--internal/game/video_poker_render_test.go2
8 files changed, 200 insertions, 12 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 5e09d48..0b16114 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -539,6 +539,20 @@ func (g *Game) executeTableAction(sess *net.Session, action string) {
g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, action))
}
+// Keep the automatic post-command prompt out of a seated player's spin
+// animation. Settlement menus resume prompting once the table leaves playing.
+func (g *Game) rouletteSpinning(sess *net.Session) bool {
+ if g.Casino == nil || sess == nil || sess.Player == nil {
+ return false
+ }
+ table, playing := g.Casino.Participation(sess.Player.Name)
+ if !playing || table == nil || table.Config.Game != casino.GameRoulette {
+ return false
+ }
+ s, ok := table.Board(sess.Player.Name).(*casino.RouletteSnapshot)
+ return ok && s.Phase == "playing"
+}
+
func (g *Game) CasinoTick() {
if g.Casino != nil {
g.emitCasinoEvents(g.Casino.Tick())
@@ -569,7 +583,10 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
message := g.casinoProse(sess, category, g.casinoTimeText(event.Message))
if details {
if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" {
- message += "\n" + rendered
+ if message != "" {
+ message += "\n"
+ }
+ message += rendered
}
}
if message != "" {
diff --git a/internal/game/game.go b/internal/game/game.go
index 3357077..e5192c0 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -573,11 +573,15 @@ func playerDataFileName(relPath string) (name, kind string, ok bool) {
}
func (g *Game) writePrompt(sess *net.Session) {
- sess.WritePrompt(g.promptStr(sess))
+ if !g.rouletteSpinning(sess) {
+ sess.WritePrompt(g.promptStr(sess))
+ }
}
func (g *Game) reprompt(sess *net.Session) {
- sess.Reprompt(g.promptStr(sess))
+ if !g.rouletteSpinning(sess) {
+ sess.Reprompt(g.promptStr(sess))
+ }
}
func (g *Game) handleGameCommand(sess *net.Session, input string) {
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
index 218a91f..a0f1e04 100644
--- a/internal/game/render_roulette.go
+++ b/internal/game/render_roulette.go
@@ -16,8 +16,10 @@ import (
func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode bool, size int) string {
switch s.Phase {
case "playing":
- // A blank line on either side keeps the wheel from crowding the prose.
- return "\n" + renderRouletteWheel(s.Wheel, mode, unicode) + "\n"
+ if s.SpinFrame == 0 {
+ return ""
+ }
+ return renderRouletteSpin(s, mode, unicode)
case "result":
return "\n" + renderRouletteResult(s.Pockets, mode, unicode, size) + "\n"
default:
@@ -27,6 +29,27 @@ func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode boo
}
}
+// A scrolling strip works in telnet scrollback without cursor-control escapes.
+func renderRouletteSpin(s *casino.RouletteSnapshot, mode string, unicode bool) string {
+ var line strings.Builder
+ for i, pocket := range s.SpinPockets {
+ fg, _ := roulettePocketColor(pocket)
+ if fg == 0 {
+ fg = 8 // visible charcoal rather than invisible black on a dark terminal
+ }
+ text := "█"
+ if !unicode {
+ text = "#"
+ }
+ if i == s.BallPosition {
+ line.WriteString(roulettePaint(mode, ".", fg))
+ } else {
+ line.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1}, text))
+ }
+ }
+ return line.String()
+}
+
func rouletteInk(mode, text string) string {
return color.Render(mode, color.ColorSpec{Fg: 15, Bg: -1}, text)
}
diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go
index 90cee0c..e4fe650 100644
--- a/internal/game/render_roulette_test.go
+++ b/internal/game/render_roulette_test.go
@@ -25,7 +25,7 @@ func TestRouletteArtModes(t *testing.T) {
t.Run(fmt.Sprintf("%s/unicode=%v/double=%v", mode, unicode, double), func(t *testing.T) {
wheelOrder := casino.WheelOrder(double)
s := &casino.RouletteSnapshot{DoubleZero: double, Wheel: wheelOrder}
- for _, phase := range []string{"betting", "playing", "result"} {
+ for _, phase := range []string{"betting", "result"} {
s.Phase = phase
s.Pockets = [5]string{"25", "26", "0", "32", "15"}
art := renderRouletteSnapshot(s, mode, unicode, 7)
@@ -377,7 +377,7 @@ func TestRouletteEventColorsDoNotWrapArt(t *testing.T) {
// Use an emphatic blue background to detect whole-message color wrapping.
sess.Account = &player.Account{Colors: map[string]string{"casino_action": "0F bg:04 bold"}}
g.Casino.Join(1, casino.TableConfig{ID: "r", Game: casino.GameRoulette}, p.Name)
- e := casino.Event{RoomID: 1, TableID: "r", Message: "Spinning now", Color: "casino_action", Public: public, Snapshot: &casino.RouletteSnapshot{Phase: "playing", Players: []string{p.Name}}}
+ e := casino.Event{RoomID: 1, TableID: "r", Message: "Spinning now", Color: "casino_action", Public: public, Snapshot: &casino.RouletteSnapshot{Phase: "playing", SpinFrame: 1, Players: []string{p.Name}}}
if !public {
e.PrivateTo = p.Name
}
@@ -575,7 +575,6 @@ func TestRouletteWheelBlackAndSpacing(t *testing.T) {
phase string
want string
}{
- {"playing", renderRouletteWheel(casino.WheelOrder(false), "none", true)},
{"result", renderRouletteResult([5]string{"25", "26", "0", "32", "15"}, "none", true, 7)},
} {
s := &casino.RouletteSnapshot{Wheel: casino.WheelOrder(false), Phase: tc.phase, Pockets: [5]string{"25", "26", "0", "32", "15"}}
diff --git a/internal/game/render_video_poker.go b/internal/game/render_video_poker.go
index 719b36a..045bdc7 100644
--- a/internal/game/render_video_poker.go
+++ b/internal/game/render_video_poker.go
@@ -161,8 +161,16 @@ func renderVideoPoker(s *casino.VideoPokerSnapshot, mode string, unicode bool, s
if i == 0 && len(art) > 0 && decorated {
art[0] += " " + badge + bonus
}
- if len(s.Lines) > 1 && len(art) > 0 {
- art[0] = fmt.Sprintf("%3d: ", i+1) + art[0]
+ indent := ""
+ if len(s.Lines) > 1 {
+ indent = " "
+ for row := range art {
+ prefix := indent
+ if row == 0 {
+ prefix = fmt.Sprintf("%3d: ", i+1)
+ }
+ art[row] = prefix + art[row]
+ }
}
lines = append(lines, art...)
text := "No win"
@@ -178,7 +186,7 @@ func renderVideoPoker(s *casino.VideoPokerSnapshot, mode string, unicode bool, s
text += fmt.Sprintf(" | earns %dx", s.Next[i])
}
// Results always follow the cards, including compact hundred-play.
- lines = append(lines, color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, text))
+ lines = append(lines, indent+color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, text))
}
if s.Result != "" {
fg := pokerYellow
diff --git a/internal/game/roulette_animation_test.go b/internal/game/roulette_animation_test.go
new file mode 100644
index 0000000..96f14bb
--- /dev/null
+++ b/internal/game/roulette_animation_test.go
@@ -0,0 +1,102 @@
+package game
+
+import (
+ "strings"
+ "testing"
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+)
+
+func TestRouletteSpinOutputHasNoPromptOrBlankLines(t *testing.T) {
+ g, sess, conn := uxCasinoGame(t)
+ g.Hub = net.NewHub()
+ g.Hub.EnterRoom(sess, 1)
+ sess.Player.Options = map[string]any{"color": "none", "unicode": true}
+ g.emitCasinoEvents(g.Casino.Join(1, casino.TableConfig{ID: "roulette", Game: casino.GameRoulette}, sess.Player.Name))
+ sess.ClearPrompt()
+ conn.data = nil
+ g.executeBet(sess, []string{"2", "on", "2"}, "bet 2 on 2")
+ g.writePrompt(sess) // the command queue's post-command prompt
+ text := strings.ReplaceAll(string(conn.data), "\r\n", "\n")
+ if !strings.HasSuffix(text, "The roulette wheel spins...\n") {
+ t.Fatalf("prompt after spin announcement: %q", text)
+ }
+ before := len(conn.data)
+ g.reprompt(sess)
+ if len(conn.data) != before {
+ t.Fatal("reprompt interrupted animation")
+ }
+ for tick := 1; tick <= 5; tick++ {
+ conn.data = nil
+ g.CasinoTick()
+ text = strings.ReplaceAll(string(conn.data), "\r\n", "\n")
+ if strings.Count(text, "\n") != 1 || !strings.HasSuffix(text, "\n") || color.VisibleLen(strings.TrimSuffix(text, "\n")) != 10 {
+ t.Fatalf("tick %d: expected exactly one strip: %q", tick, text)
+ }
+ if tick == 5 && strings.Count(text, ".") != 1 {
+ t.Fatal("final strip missing ball")
+ }
+ }
+ for tick := 6; tick <= 7; tick++ {
+ conn.data = nil
+ g.CasinoTick()
+ if len(conn.data) != 0 {
+ t.Fatalf("tick %d: expected silence: %q", tick, conn.data)
+ }
+ }
+ g.CasinoTick()
+ if !strings.Contains(string(conn.data), "The ball lands in") || g.rouletteSpinning(sess) {
+ t.Fatal("result did not end animation")
+ }
+ sess.ClearPrompt()
+ conn.data = nil
+ g.writePrompt(sess)
+ if len(conn.data) == 0 {
+ t.Fatal("prompt did not resume")
+ }
+}
+
+func TestRouletteSpinStrip(t *testing.T) {
+ for _, mode := range []string{"none", "ansi", "xterm256"} {
+ for _, unicode := range []bool{false, true} {
+ for _, ball := range []int{-1, 5} {
+ s := &casino.RouletteSnapshot{Phase: "playing", SpinFrame: 1, BallPosition: ball, SpinPockets: [10]string{"1", "2", "1", "2", "1", "26", "0", "32", "15", "19"}}
+ art := renderRouletteSnapshot(s, mode, unicode, 7)
+ plain := plainRoulette(art)
+ dots := 0
+ if ball >= 0 {
+ dots = 1
+ }
+ if color.VisibleLen(art) != 10 || strings.Count(plain, ".") != dots || strings.Contains(plain, "\n") {
+ t.Fatalf("bad strip: %q", art)
+ }
+ block := "█"
+ if !unicode {
+ block = "#"
+ }
+ if strings.Count(plain, block) != 10-dots {
+ t.Fatalf("bad blocks: %q", plain)
+ }
+ if mode != "none" && ball >= 0 && !strings.Contains(art, roulettePaint(mode, ".", 8)) {
+ t.Fatalf("26 ball is not black: %q", art)
+ }
+ }
+ for _, pocket := range []string{"0", "00", "26", "32"} {
+ s := &casino.RouletteSnapshot{Phase: "playing", SpinFrame: 5, BallPosition: 4}
+ s.SpinPockets[4] = pocket
+ art := renderRouletteSnapshot(s, mode, unicode, 7)
+ fg, _ := roulettePocketColor(pocket)
+ if fg == 0 {
+ fg = 8
+ }
+ if !strings.Contains(art, roulettePaint(mode, ".", fg)) {
+ t.Fatalf("wrong ball color for %s: %q", pocket, art)
+ }
+ }
+ }
+ }
+ if got := renderRouletteSnapshot(&casino.RouletteSnapshot{Phase: "playing"}, "none", true, 7); got != "" {
+ t.Fatalf("old wheel still shown: %q", got)
+ }
+}
diff --git a/internal/game/video_poker_alignment_test.go b/internal/game/video_poker_alignment_test.go
new file mode 100644
index 0000000..0163fac
--- /dev/null
+++ b/internal/game/video_poker_alignment_test.go
@@ -0,0 +1,35 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+)
+
+func TestPokerMultiHandCardRowsAlignWithLabels(t *testing.T) {
+ cards := []casino.CardView{{Rank: "A", Suit: "hearts"}, {Rank: "K", Suit: "spades"}, {Rank: "Q", Suit: "diamonds"}, {Rank: "J", Suit: "clubs"}, {Rank: "10", Suit: "hearts"}}
+ for _, size := range []int{1, 3, 5} {
+ t.Run(fmt.Sprint(size), func(t *testing.T) {
+ hand := casino.PokerLine{Cards: cards, Category: "Straight", Coins: 20, Multiplier: 1}
+ snap := &casino.VideoPokerSnapshot{Config: (casino.VideoPokerConfig{Cabinet: casino.CabinetClassic}).Normalize(), Hands: 3, Lines: []casino.PokerLine{hand, hand, hand}}
+ out := renderVideoPoker(snap, "none", true, size, "", nil)
+ art := renderPokerCards(cards, nil, "none", true, min(5, cardRenderSize(true, size)), "", nil)
+ for n := 1; n <= 3; n++ {
+ rows := append([]string(nil), art...)
+ for i := range rows {
+ prefix := " "
+ if i == 0 {
+ prefix = fmt.Sprintf("%3d: ", n)
+ }
+ rows[i] = prefix + rows[i]
+ }
+ want := strings.Join(rows, "\n") + "\n Straight"
+ if !strings.Contains(out, want) {
+ t.Fatalf("hand %d is misaligned:\n%s", n, out)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/game/video_poker_render_test.go b/internal/game/video_poker_render_test.go
index 88c8474..1f76d60 100644
--- a/internal/game/video_poker_render_test.go
+++ b/internal/game/video_poker_render_test.go
@@ -100,7 +100,7 @@ func TestPokerResultsBelowCardsAndHoldStyle(t *testing.T) {
}
result := strings.Index(plain, text)
// The result starts its own line and follows the card/badge line.
- if result < 0 || result == 0 || plain[result-1] != '\n' {
+ if result <= 0 || strings.TrimSpace(plain[strings.LastIndex(plain[:result], "\n")+1:result]) != "" {
t.Fatalf("result not below cards: %s", plain)
}
if hands == 100 && !strings.Contains(plain, "♥") {