ReboundEmulator/Core/BowlingGame.cs
Moonbase 84587f6ce4 Implement Multi Applet Support To Game Page
Implement Experimental Pogo Bowl Support (and in turn Leaderboard games in general)
Remove "StaticFiles" In Favor Of App Base Directory Storage
New `logn` Sequence
Fixed Incorrect Jackpot Result Response (although it did uncover lost media with spin.rslt=1)
2026-09-28 19:39:01 -07:00

309 lines
13 KiB
C#

using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Reflection.Metadata.Ecma335;
namespace NotPogo.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
}
}