初始化 Line_Project_Bot:LP_Bot 重構版(介面重設計+平台環境改讀試算表)

根本原因:
LP_Bot 介面老舊且環境設定寫死在程式內,新增平台或環境都要改碼重建。

影響:
全新深色控制台介面;首頁改為「平台+環境+Token」,平台清單與連線設定
即時讀取 Google 試算表(quickopen 同款資料來源),選擇與 Token 會記憶於
localStorage;支援「開遊戲」直開跳過維護網址;SD 機台自製 Spin 面板
(押注/延遲/倍率停/轉數停+Log 終端)。

修法:
- 底層引擎(Engine/Common/define/FormTable*)自 LP_Bot 原封搬移
- UI 層全部重寫(Login/Lobby/SlotList/SDGame),antd 深色主題
- 新增 GoogleSheetService:service account 簽 JWT 讀 Sheets API,
  非安全來源 fallback jsrsasign
- 移除 React.StrictMode(雙重掛載會讓 NetManagerSD 單例連線互蓋)
- SD socket 依 gameUrl 協定強制 wss
- 機台 1310/1311/1801/1803/1804/1805 Spin 帶 mode:0 特例
  (依 SD3 客戶端 MainState 原始碼)
- credentials.json 不進版控,附 credentials.example.json 範本

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:04:49 +08:00
co-authored by Claude Fable 5
commit ebe8c3caca
445 changed files with 42552 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
import { CurrencyManager } from "@/FormTableExt/Manage/Currency/CurrencyManager";
import { useGameItems } from "@/context/GameItemsContext";
import React, { useEffect } from "react";
/** 頂部玩家資訊列 */
const Player = () => {
const { player } = useGameItems();
const { aId, name, m } = player;
function onLoad() {
}
useEffect(() => {
onLoad();
}, []);
return (
<header style={barStyle} className="lp-rise">
<div style={brandStyle}>
<span className="lp-dot" />
<span className="lp-kicker">LP BOT</span>
</div>
<div style={statsStyle}>
<div style={statStyle}>
<span className="lp-label">AID</span>
<span className="lp-mono" style={statValueStyle}>{aId ?? "-"}</span>
</div>
<div style={dividerStyle} />
<div style={statStyle}>
<span className="lp-label"></span>
<span style={{ ...statValueStyle, fontFamily: "var(--lp-font-body)" }}>{name ?? "-"}</span>
</div>
<div style={dividerStyle} />
<div style={statStyle}>
<span className="lp-label"></span>
<span className="lp-mono" style={{ ...statValueStyle, color: "var(--lp-amber)" }}>
{CurrencyManager.GetNumberWithComma(m)}
</span>
</div>
</div>
</header>
);
};
export default Player;
const barStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
padding: "12px 20px",
borderBottom: "1px solid var(--lp-line)",
background: "linear-gradient(180deg, rgba(22, 29, 40, 0.9), rgba(16, 21, 29, 0.9))",
backdropFilter: "blur(6px)",
flexWrap: "wrap",
};
const brandStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
};
const statsStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 18,
flexWrap: "wrap",
};
const statStyle: React.CSSProperties = {
display: "flex",
alignItems: "baseline",
gap: 8,
};
const statValueStyle: React.CSSProperties = {
fontSize: 15,
fontWeight: 600,
color: "var(--lp-text)",
};
const dividerStyle: React.CSSProperties = {
width: 1,
height: 18,
background: "var(--lp-line)",
};
+361
View File
@@ -0,0 +1,361 @@
import MainControl from "@/Common/MainControl/MainControl";
import CSMessage from "@/Common/Message/CSMessage";
import { CoroutineV2 } from "@/Engine/CatanEngine/CoroutineV2/CoroutineV2";
import { INetResponse } from "@/Engine/CatanEngine/NetManagerV2/Core/INetResponse";
import { NetConnector } from "@/Engine/CatanEngine/NetManagerV2/NetConnector";
import { NetManagerSD } from "@/Engine/CatanEngine/NetManagerV2/NetManagerSD";
import CSSettingsSDV3 from "@/FormTableSD/CSSettingsSDV3";
import { SlotsetTableRow } from "@/FormTableSD/Tables/SlotsetTable";
import { gameObj, useGameItems } from "@/context/GameItemsContext";
import { SDAccountLoginRequest } from "@/define/Request/AccountRequest";
import { SlotInRequest } from "@/define/Request/SlotRequest";
import GameManager from "@/modules/GameManager";
import WheelSelect from "@/components/WheelSelect";
import { Button, InputNumber, Switch } from "antd";
import dayjs from "dayjs";
import React, { useEffect, useState } from "react";
const SDGame = (props: ISDGame) => {
const { gameUrl, onClickSlotOut } = props;
const { gameData } = useGameItems();
const { nowSlotId } = gameData;
const [isOK, setIsOK] = useState<boolean>(false);
const [isSpin, setIsSpin] = useState<boolean>(false);
const [betOptions, setBetOptions] = useState<number[]>([]);
const [bet, setBet] = useState<number>(0);
const [delay, setDelay] = useState<number>(0.1);
const [isRatioStop, setIsRatioStop] = useState<boolean>(false);
const [ratioStop, setRatioStop] = useState<number>(200);
const [isCountStop, setIsCountStop] = useState<boolean>(false);
const [countStop, setCountStop] = useState<number>(200);
const [log, setLog] = useState<string[]>([]);
let conn: NetConnector = undefined;
function* onLoad(): IterableIterator<any> {
reset();
const url: URL = new URL(gameUrl);
const queryParameters: URLSearchParams = new URLSearchParams(url.search);
const token: string = queryParameters.get("token");
const slotid: number = +queryParameters.get("slotid");
let host: string = queryParameters.get("host");
const port: number = 9005;
// NetConnector 對沒帶協定的 host 會用「目前頁面」的協定判斷 ws/wss,
// bot 頁面是 http 會誤判成 ws://;改依遊戲網址本身的協定,https 就強制 wss
if (url.protocol === "https:" && !/^https?:\/\//.test(host)) {
host = `https://${host}`;
}
GameManager.SlotData.SlotId = slotid;
GameManager.SlotData.IsRatioStop = isRatioStop;
GameManager.SlotData.RatioStop = ratioStop;
GameManager.SlotData.IsCountStop = isCountStop;
GameManager.SlotData.CountStop = countStop;
conn = new NetConnector(host, port);
conn.OnDataReceived.AddCallback(onNetDataReceived);
conn.OnDisconnected.AddCallback(onNetDisconnected);
loadJSON(slotid);
setSlotClass();
NetManagerSD.Initialize(conn);
console.log("[SDsocket] connecting...");
AddLog(`連線 SD socket: ${host}:${port}`);
yield NetManagerSD.ConnectAsync();
AddLog("SD socket 已連線");
yield* login(token);
yield* slotIn();
}
function onClose(): void {
CoroutineV2.StopCoroutinesBy("Spin");
try {
if (conn && conn.IsConnected) {
conn.Disconnect();
}
} catch (e) {
//
}
reset();
}
function reset(): void {
GameManager.SlotData.SlotId = undefined;
GameManager.SlotData.SlotClass = undefined;
GameManager.SlotData.IsSpin = undefined;
GameManager.SlotData.NowBet = undefined;
GameManager.SlotData.IsRatioStop = undefined;
GameManager.SlotData.RatioStop = undefined;
GameManager.SlotData.IsCountStop = undefined;
GameManager.SlotData.CountStop = undefined;
}
function* login(token: string): IterableIterator<any> {
const req = new SDAccountLoginRequest(token);
yield req.SendAsync(true);
const resp = req.Result;
if (!resp.IsValid) {
CSMessage.NetError(resp.Method, resp.Status, "SD Account Login Fail");
AddLog(`SD 登入失敗 [${resp.Method}] status:${resp.Status}`);
return;
}
AddLog("SD 登入成功");
}
function* slotIn(): IterableIterator<any> {
let req: SlotInRequest = new SlotInRequest(GameManager.SlotData.SlotId);
yield req.SendAsync(true);
let resp: INetResponse<JSON> = req.Result;
if (resp.IsValid) {
const br: number[] = resp.Data["br"];
const db: number = resp.Data["db"];
setBetOptions(br);
setBet(br[db]);
GameManager.SlotData.NowBet = br[db];
setIsOK(true);
AddLog(`機台就緒 押注選項: [${br.join(", ")}]`);
} else {
AddLog(`SlotIn 失敗 [${resp.Method}] status:${resp.Status}`);
}
}
async function loadJSON(slotid: number) {
let slotset: SlotsetTableRow = CSSettingsSDV3.Slotset[slotid];
let formName: string[] = slotset.FormName;
let parallel: Promise<void>[] = [];
for (let i: number = 0; i < formName.length; i++) {
await MainControl.DownloadFormSetting(formName[i]);
}
// set Form
await Promise.all(parallel);
}
async function setSlotClass() {
let slot: any;
const slotGroup: typeof import("../../define/Game/Base/Slot") = await import(/* @vite-ignore */`../../define/Game/Base/Slot`);
try {
slot = slotGroup[`Slot${GameManager.SlotData.SlotId}`];
} catch (error) {
//
}
if (!slot) {
slot = slotGroup.SlotBase;
}
GameManager.SlotData.SlotClass = new slot(GameManager.SlotData.SlotId, AddLog);
}
async function OnClickSpin() {
GameManager.SlotData.IsSpin = true;
setIsSpin(true);
CoroutineV2.Single(spin()).Start("Spin");
}
function OnClickStop() {
GameManager.SlotData.IsSpin = false;
setIsSpin(false);
}
function* spin(): IterableIterator<any> {
const { player } = gameObj;
const { m: money } = player;
if (money < GameManager.SlotData.NowBet) {
noMoney();
OnClickStop();
return;
}
if (isCountStop) {
if (GameManager.SlotData.CountStop <= 0) {
OnClickStop();
return;
}
GameManager.SlotData.CountStop--;
setCountStop((v) => v - 1);
}
yield* GameManager.SlotData.SlotClass.Spin(bet, OnClickStop);
yield CoroutineV2.WaitTime(delay);
if (GameManager.SlotData.IsSpin) {
yield* spin();
} else {
GameManager.SlotData.IsSpin = false;
setIsSpin(false);
}
}
function noMoney(): void {
const { player } = gameObj;
const { m: money } = player;
GameManager.SlotData.IsSpin = false;
AddLog(`金額不足: ${money}`);
}
function AddLog(s: string) {
const todayTimeStr: string = dayjs().format("YYYY/MM/DD HH:mm:ss");
setLog((v) => {
const next: string[] = [`${todayTimeStr} ${s}`, ...v];
return next.length > 100 ? next.slice(0, 100) : next;
});
}
/**RPC回傳.若協定錯誤斷線.原因也會在這裡收到 */
function onNetDataReceived(resp: INetResponse<any>) {
// MainControl.DataReceivedEvent.DispatchCallback([MainControl.DataType.ServerData, resp]);
}
/**只要連線中斷不管主被動都會走到這裡 */
function onNetDisconnected() {
console.warn("[socket] Disconnected");
conn.OnDataReceived.RemoveAllCallbacks();
// MainControl.DataReceivedEvent.DispatchCallback([MainControl.DataType.NetDisconnected]);
}
useEffect(() => {
CoroutineV2.Single(onLoad()).Start();
return onClose;
}, []);
return (
<div style={layoutStyle} className="lp-rise">
{/* 左側:控制面板 */}
<div className="lp-panel lp-corners" style={controlPanelStyle}>
<div style={{ marginBottom: 18 }}>
<div className="lp-kicker" style={{ marginBottom: 6 }}>SD Machine</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<h2 className="lp-title" style={{ fontSize: 24 }}>SLOT <span className="lp-mono" style={{ color: "var(--lp-amber)" }}>#{nowSlotId}</span></h2>
<span className={`lp-dot ${isSpin ? "lp-dot--red" : isOK ? "" : "lp-dot--amber"}`} />
<span className="lp-label">{isSpin ? "spinning" : isOK ? "ready" : "loading"}</span>
</div>
</div>
{isOK && <>
<div style={rowStyle}>
<span className="lp-label" style={rowLabelStyle}></span>
<WheelSelect
wrapperStyle={{ flex: 1 }}
style={{ width: "100%" }}
value={bet}
disabled={isSpin}
options={betOptions.map((v: number) => ({ value: v, label: v.toLocaleString() }))}
onChange={(v: number) => { setBet(v); GameManager.SlotData.NowBet = v; }}
/>
</div>
<div style={rowStyle}>
<span className="lp-label" style={rowLabelStyle}></span>
<InputNumber
style={{ flex: 1 }}
min={0}
step={0.1}
value={delay}
onChange={(v: number) => setDelay(+v || 0)}
addonAfter="秒"
/>
</div>
<div style={rowStyle}>
<span className="lp-label" style={rowLabelStyle}></span>
<Switch
checked={isRatioStop}
onChange={(checked: boolean) => { setIsRatioStop(checked); GameManager.SlotData.IsRatioStop = checked; }}
/>
{isRatioStop &&
<InputNumber
style={{ flex: 1 }}
min={0}
value={ratioStop}
onChange={(v: number) => { const n = +v || 0; setRatioStop(n); GameManager.SlotData.RatioStop = n; }}
addonAfter="倍"
/>
}
</div>
<div style={rowStyle}>
<span className="lp-label" style={rowLabelStyle}></span>
<Switch
checked={isCountStop}
onChange={(checked: boolean) => { setIsCountStop(checked); GameManager.SlotData.IsCountStop = checked; }}
/>
{isCountStop &&
<InputNumber
style={{ flex: 1 }}
min={0}
value={countStop}
onChange={(v: number) => { const n = +v || 0; setCountStop(n); GameManager.SlotData.CountStop = n; }}
addonAfter="轉"
/>
}
</div>
<div style={{ marginTop: "auto", paddingTop: 20 }}>
{isSpin
? <button className="lp-spin-btn lp-spin-btn--stop" onClick={OnClickStop}>STOP</button>
: <button className="lp-spin-btn" onClick={OnClickSpin}>SPIN</button>
}
<Button danger block onClick={onClickSlotOut} style={{ marginTop: 12 }}>
</Button>
</div>
</>}
{!isOK &&
<div style={{ marginTop: "auto" }}>
<Button danger block onClick={onClickSlotOut}></Button>
</div>
}
</div>
{/* 右側:Log 終端 */}
<div style={logPanelStyle}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
<span className="lp-kicker">Log</span>
<span className="lp-label">{log.length} </span>
</div>
<div className="lp-log">
{log.length === 0 && <p className="lp-cursor" style={{ color: "var(--lp-text-dim)" }}></p>}
{log.map((s: string, index: number) => <p key={index}>{s}</p>)}
</div>
</div>
</div>
);
};
export default SDGame;
interface ISDGame {
gameUrl: string;
onClickSlotOut: () => void;
}
const layoutStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: "flex",
gap: 16,
alignItems: "stretch",
};
const controlPanelStyle: React.CSSProperties = {
width: 340,
flexShrink: 0,
padding: "24px 24px 20px",
display: "flex",
flexDirection: "column",
overflowY: "auto",
};
const rowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 12,
marginBottom: 16,
};
const rowLabelStyle: React.CSSProperties = {
width: 52,
flexShrink: 0,
};
const logPanelStyle: React.CSSProperties = {
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
};
+149
View File
@@ -0,0 +1,149 @@
import CSMessage from "@/Common/Message/CSMessage";
import { CoroutineV2 } from "@/Engine/CatanEngine/CoroutineV2/CoroutineV2";
import { INetResponse } from "@/Engine/CatanEngine/NetManagerV2/Core/INetResponse";
import CSSettingsSDV3 from "@/FormTableSD/CSSettingsSDV3";
import BusinessTypeSetting, { BusinessEnum } from "@/_BusinessTypeSetting/BusinessTypeSetting";
import Image from "@/components/Image/Image";
import { useGameItems } from "@/context/GameItemsContext";
import { GameLaunchRequest, GameLeaveRequest, RpcGameLaunchResponse } from "@/define/Request/GameRequest";
import { SlotData } from "@/define/gameData";
import GameManager from "@/modules/GameManager";
import { Button, Empty } from "antd";
import React, { useEffect, useState } from "react";
import SDGame from "./SDGame";
/** 取得目前環境對應的 SD compony ID */
function getSDType(): number {
const UseServerType = BusinessTypeSetting.UseServerType;
const ServerType = BusinessEnum.ServerType[UseServerType];
return BusinessEnum.ComponyType[ServerType];
}
const SlotList = () => {
const { gameData, setGameData } = useGameItems();
const { slotData, slotList, nowSlotId } = gameData;
const [isGameIn, setIsGameIn] = useState<boolean>(false);
const [gameUrl, setGameUrl] = useState<string>("");
function onLoad() {
}
function* onClickSlotIn(slotId: number): IterableIterator<any> {
const data: SlotData = slotData[slotId];
const [componyID] = data;
const req: GameLaunchRequest = new GameLaunchRequest(componyID, slotId);
yield req.SendAsync();
const resp: INetResponse<RpcGameLaunchResponse> = req.Result;
if (!resp.IsValid) {
if (resp.Status === 18) {
CSMessage.CreateYesMsg(CSSettingsSDV3.prototype.CommonString(16));
}
return;
}
setIsGameIn(true);
GameManager.IsInGame = true;
const url: string = resp.Data;
setGameData({
...gameData,
nowSlotId: slotId
});
console.log(`[SlotIn] slot:${slotId} componyID:${componyID} SDType:${getSDType()}${componyID === getSDType() ? "SD面板" : "開新視窗"}`);
if (componyID === getSDType()) {
setGameUrl(url);
}
else {
window.open(url, "_blank");
}
}
function onClickSlotOut() {
const gameLeaveReq: GameLeaveRequest = new GameLeaveRequest(nowSlotId);
gameLeaveReq.Send();
setGameUrl("");
setIsGameIn(false);
GameManager.IsInGame = false;
}
useEffect(() => {
onLoad();
}, []);
// 遊戲中:SD 開自製 Spin 面板,其餘顯示外開視窗提示
if (isGameIn) {
if (gameUrl) {
return <SDGame gameUrl={gameUrl} onClickSlotOut={onClickSlotOut} />;
}
return (
<div className="lp-panel lp-corners lp-rise" style={externalStyle}>
<div className="lp-kicker" style={{ marginBottom: 10 }}>External Game</div>
<h2 className="lp-title" style={{ fontSize: 22, marginBottom: 6 }}> {nowSlotId} </h2>
<p style={{ color: "var(--lp-text-dim)", margin: "0 0 24px" }}></p>
<Button danger type="primary" size="large" onClick={onClickSlotOut} style={{ minWidth: 160 }}>
</Button>
</div>
);
}
// 機台清單
return (
<div style={wrapStyle} className="lp-rise">
<div style={headRowStyle}>
<span className="lp-kicker">Machines</span>
<span className="lp-label"> {slotList.length} </span>
</div>
<div style={gridStyle}>
{slotList.length === 0 &&
<Empty description={<span style={{ color: "var(--lp-text-dim)" }}>...</span>} style={{ margin: "auto" }} />
}
{slotList.map((slotId: number, index: number) => {
const isSD: boolean = slotData[slotId]?.[0] === getSDType();
return (
<div key={index} className="lp-slot-card" style={{ animationDelay: `${Math.min(index * 0.02, 0.4)}s` }}
onClick={() => { CoroutineV2.Single(onClickSlotIn(slotId)).Start(); }}>
{isSD && <span className="lp-slot-badge">SD</span>}
<Image src={`${BusinessTypeSetting.UseDownloadUrl}vendor_game/${slotId}/s`} />
<div className="lp-slot-id">#{slotId}</div>
</div>
);
})}
</div>
</div>
);
};
export default SlotList;
const wrapStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
};
const headRowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 12,
};
const gridStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
overflowY: "auto",
display: "flex",
flexWrap: "wrap",
alignContent: "flex-start",
gap: 12,
paddingBottom: 12,
};
const externalStyle: React.CSSProperties = {
margin: "auto",
maxWidth: 440,
width: "100%",
padding: "36px 36px 32px",
textAlign: "center",
};