ReboundEmulator/Core/BowlingGame.cs
Moonbase 64273ab651 Code Cleanup
Rework How Room OPs Are Handled Per Room (if the game server itself isn't handling it, just send it to the room every time)
2026-10-09 14:36:32 -07:00

307 lines
13 KiB
C#

using System.Net.Sockets;
namespace ReboundEmulator.Core
{
public class BowlingGame
{
public int CurrentScore { get; set; }
public int CurrentRoundNumber { get; set; }
public string LeaderboardData { get; set; } = string.Empty;
public string FrameCardData { get; set; } = string.Empty;
public BowlingGameState State { get; set; }
public int PrepTimerMili { get; set; } = 15000;
public int BetweenRoundTimerMili { get; set; } = 20000;
public int RoundTimerMili { get; set; } = 25000;
public int NextGameTimerMili { get; set; } = 30000;
private const int GameId = 1;
private const int FrameCount = 10;
private const string ScreenName = "NotPogoUser";
// Ball pins thrown per frame, e.g. _frames[0] = [10], _frames[1] = [7, 2]
private readonly List<List<int>> _frames = new();
// Cumulative per-frame score, indexed the same as _frames, filled in as frames complete/resolve
private readonly List<int> _frameScores = new();
// Signaled by RecordThrow() when the current frame is finished (strike, or all balls thrown)
private TaskCompletionSource<bool>? _frameDoneSignal;
public string GetStateString()
{
switch (State)
{
case BowlingGameState.PreparingRound:
return "gm.pre";
case BowlingGameState.RoundStarted:
return "gm.str";
case BowlingGameState.RoundOver:
return "gm.btw";
case BowlingGameState.GameEnded:
return "gm.end";
}
return "gm.inval";
}
public void IncrementScore(int count) => CurrentScore += count;
// ---------------------------------------------------------------
// Call this from wherever co.turn is currently handled, passing
// the number of pins knocked down on that single throw.
// ---------------------------------------------------------------
public void RecordThrow(int pins)
{
if (_frames.Count <= CurrentRoundNumber)
_frames.Add(new List<int>());
_frames[CurrentRoundNumber].Add(pins);
IncrementScore(pins);
if (IsFrameComplete(CurrentRoundNumber, _frames[CurrentRoundNumber]))
_frameDoneSignal?.TrySetResult(true);
}
private static bool IsFrameComplete(int frameIndex, List<int> balls)
{
if (frameIndex < 9)
return (balls.Count == 1 && balls[0] == 10) || balls.Count == 2;
// 10th frame: 3 balls if it opened with a strike or a spare, otherwise 2
if (balls.Count < 2) return false;
if (balls.Count == 2) return balls[0] != 10 && balls[0] + balls[1] != 10;
return true;
}
// Classic ball-list bowling scorer. A frame that just completed with a strike/spare
// will read low until the bonus ball(s) in the following frame(s) are thrown -- that
// matches how a real scoreboard behaves (shows "--" until the bonus resolves).
private static List<int> ScoreFrames(List<int> rolls)
{
var scores = new List<int>();
int idx = 0;
for (int frame = 0; frame < FrameCount; frame++)
{
int prev = frame > 0 ? scores[frame - 1] : 0;
if (idx >= rolls.Count) { scores.Add(prev); continue; }
if (rolls[idx] == 10) // strike
{
scores.Add(prev + 10 + Sum(rolls, idx + 1, 2));
idx += 1;
}
else if (idx + 1 < rolls.Count && rolls[idx] + rolls[idx + 1] == 10) // spare
{
scores.Add(prev + 10 + Sum(rolls, idx + 2, 1));
idx += 2;
}
else
{
scores.Add(prev + Sum(rolls, idx, 2));
idx += 2;
}
}
return scores;
}
private static int Sum(List<int> l, int start, int count) =>
Enumerable.Range(start, count).Where(i => i < l.Count).Sum(i => l[i]);
// frameIndex is 0-based; cumScore is that frame's running total from ScoreFrames()
private static string BuildFrameString(int frameIndex, List<int> balls, int cumScore)
{
int b0 = balls.Count > 0 ? balls[0] : -1;
int b1 = balls.Count > 1 ? balls[1] : -1;
int b2 = balls.Count > 2 ? balls[2] : -1;
// frameNumber:score:field3:ball1:ball2:ball3:field7:field8 -- field3/7/8 have no
// confirmed reader in the client, 0 is safe.
return $"{frameIndex + 1}:{cumScore}:0:{b0}:{b1}:{b2}:0:0";
}
private static string BuildGst(int gameId, int round, List<int> allBallsThisGame, List<int> ballsPerFrameCount, int currentFrame)
{
var c = new int[21];
Array.Fill(c, -1);
for (int i = 0; i < allBallsThisGame.Count && i < 21; i++)
c[i] = allBallsThisGame[i];
var d = new int[10];
int cursor = 0;
for (int f = 0; f < 10; f++)
{
d[f] = cursor;
if (f < ballsPerFrameCount.Count) cursor += ballsPerFrameCount[f];
}
float floatA = currentFrame; // 1-indexed, must be >= 1.0
int intB = allBallsThisGame.Count;
return $"{gameId},{round},{floatA},{intB},{string.Join(",", c)},{string.Join(",", d)},0.0";
}
// Pulls the flat ball list + current gst/rid strings out of the tracked frame state,
// keeping FrameCardData/CurrentScore in sync with the public properties too.
private (string rid, string gst) SnapshotState(int roundIndex)
{
var flat = _frames.SelectMany(f => f).ToList();
_frameScores.Clear();
_frameScores.AddRange(ScoreFrames(flat));
string rid = _frames.Count > roundIndex
? BuildFrameString(roundIndex, _frames[roundIndex], _frameScores[roundIndex])
: BuildFrameString(roundIndex, new List<int>(), 0);
string gst = BuildGst(GameId, roundIndex + 1, flat, _frames.Select(f => f.Count).ToList(), roundIndex + 1);
FrameCardData = rid;
CurrentScore = _frameScores.Count > 0 ? _frameScores[^1] : 0;
return (rid, gst);
}
// Build the "last 3 frames" leaderboard entry safely, whatever _frames.Count currently is
private string BuildLeaderboardEntry()
{
int start = Math.Max(0, _frames.Count - 3);
var frameStrs = new List<string>();
for (int f = start; f < _frames.Count && frameStrs.Count < 3; f++)
frameStrs.Add(BuildFrameString(f, _frames[f], f < _frameScores.Count ? _frameScores[f] : 0));
while (frameStrs.Count < 3) frameStrs.Add(BuildFrameString(frameStrs.Count, new List<int>(), 0));
return $"1;{ScreenName},{CurrentScore},{string.Join(",", frameStrs)}";
}
public async Task StartGameLoop(NetworkStream stream)
{
for (int i = 0; i < FrameCount; i++) // main game loop
{
CurrentRoundNumber = i;
_frames.Add([]);
_frameDoneSignal = new TaskCompletionSource<bool>();
State = BowlingGameState.PreparingRound;
var (rid, gst) = SnapshotState(i);
if (i == 0)
{
// this is the first round, send full gm.pre message
await TCPGameServer.SendAsync(stream,
("msg.op", "so.gb.stat"),
("gid", GameId.ToString()),
("rnd", (CurrentRoundNumber + 1).ToString()),
("gmlr", "0"),
("state", GetStateString()),
("ktok", "1"),
("scrn", ScreenName),
("rid", rid),
("gst", gst),
("bowlcom.numRounds", "10"),
("bowlcom.jackpotPoints", "500"),
("bowlcom.ui.standard", "true"),
("bowlcom.tok.silver", "3,6,9"),
("bowlcom.tok.gold", "0,0,0"), // must be 3 comma-separated values, same as silver -- a
// single bare "0" throws a StringTokenizer NPE client-side
("bowlcom.fast", "false")
);
}
else
{
await TCPGameServer.SendAsync(stream,
("msg.op", "so.gb.stat"),
("gid", GameId.ToString()),
("rnd", (CurrentRoundNumber + 1).ToString()),
("gmlr", "0"),
("state", GetStateString()),
("ktok", "1"),
("scrn", ScreenName),
("rid", rid),
("gst", gst)
);
}
// wait prep timer
await Task.Delay(PrepTimerMili);
State = BowlingGameState.RoundStarted;
(rid, gst) = SnapshotState(i);
// send status update to indicate round is starting
await TCPGameServer.SendAsync(stream,
("msg.op", "so.gb.stat"),
("gid", GameId.ToString()),
("rnd", (CurrentRoundNumber + 1).ToString()),
("gmlr", "0"),
("state", GetStateString()),
("ktok", "1"),
("scrn", ScreenName),
("rid", rid),
("gst", gst)
);
// start round -- this is what actually starts the client's countdown UI
await TCPGameServer.SendAsync(stream,
("msg.op", "so.gb.strt"),
("gid", GameId.ToString()),
("rnd", (CurrentRoundNumber + 1).ToString()),
("mili", RoundTimerMili.ToString())
);
// wait for the player to finish the frame, or the round timer to run out --
// whichever happens first. RecordThrow() (called from your co.turn handler)
// completes _frameDoneSignal once the frame is done.
await Task.WhenAny(_frameDoneSignal.Task, Task.Delay(RoundTimerMili));
State = BowlingGameState.RoundOver;
(rid, gst) = SnapshotState(i);
// send status update with the now-final scorecard for this frame
await TCPGameServer.SendAsync(stream,
("msg.op", "so.gb.stat"),
("gid", GameId.ToString()),
("rnd", (CurrentRoundNumber + 1).ToString()),
("gmlr", "0"),
("state", GetStateString()),
("ktok", "1"),
("scrn", ScreenName),
("rid", rid),
("gst", gst)
);
// send end round
await TCPGameServer.SendAsync(stream,
("msg.op", "so.gb.endr"),
("gid", GameId.ToString()),
("rnd", (CurrentRoundNumber + 1).ToString()),
("mili", BetweenRoundTimerMili.ToString()),
("csts", "0"),
("cprm", "0")
);
// send leaderboard update
LeaderboardData = BuildLeaderboardEntry();
await TCPGameServer.SendAsync(stream, ("msg.op", "so.gb.ledr"), ("lbrd", LeaderboardData));
await Task.Delay(BetweenRoundTimerMili);
}
// end of game -- the loop above never sent this before, so the client had no
// signal the game was actually over
State = BowlingGameState.GameEnded;
await TCPGameServer.SendAsync(stream,
("msg.op", "so.gb.endg"),
("gid", GameId.ToString()),
("mili", "0")
);
LeaderboardData = BuildLeaderboardEntry();
await TCPGameServer.SendAsync(stream, ("msg.op", "so.gb.ledr"), ("lbrd", LeaderboardData));
}
}
public enum BowlingGameState
{
PreparingRound,
RoundStarted,
RoundOver,
GameEnded
}
}