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> _frames = new(); // Cumulative per-frame score, indexed the same as _frames, filled in as frames complete/resolve private readonly List _frameScores = new(); // Signaled by RecordThrow() when the current frame is finished (strike, or all balls thrown) private TaskCompletionSource? _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()); _frames[CurrentRoundNumber].Add(pins); IncrementScore(pins); if (IsFrameComplete(CurrentRoundNumber, _frames[CurrentRoundNumber])) _frameDoneSignal?.TrySetResult(true); } private static bool IsFrameComplete(int frameIndex, List 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 ScoreFrames(List rolls) { var scores = new List(); 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 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 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 allBallsThisGame, List 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(), 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(); 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(), 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(); 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 } }