528 lines
14 KiB
TypeScript
528 lines
14 KiB
TypeScript
import type {
|
|
ChatGroup,
|
|
ChatMessage,
|
|
Player,
|
|
SessionSnapshot,
|
|
TakeoverRequest,
|
|
Transaction,
|
|
} from "../shared/types";
|
|
import type { Session } from "./types";
|
|
import { DomainError } from "./errors";
|
|
import {
|
|
clampBalance,
|
|
createId,
|
|
now,
|
|
parseAmount,
|
|
DEFAULT_START_BALANCE,
|
|
} from "./util";
|
|
|
|
const MAX_CHAT_LENGTH = 280;
|
|
|
|
export function snapshotSession(session: Session): SessionSnapshot {
|
|
return {
|
|
id: session.id,
|
|
code: session.code,
|
|
status: session.status,
|
|
createdAt: session.createdAt,
|
|
bankerId: session.bankerId,
|
|
blackoutActive: session.blackoutActive,
|
|
blackoutReason: session.blackoutReason,
|
|
players: Array.from(session.players.values()),
|
|
transactions: session.transactions,
|
|
chats: session.chats,
|
|
groups: session.groups,
|
|
takeoverRequests: session.takeoverRequests,
|
|
};
|
|
}
|
|
|
|
function remapPlayerId(value: string, fromId: string, toId: string): string {
|
|
return value === fromId ? toId : value;
|
|
}
|
|
|
|
function remapSnapshotBanker(snapshot: SessionSnapshot, banker: Player): SessionSnapshot {
|
|
const fromId = snapshot.bankerId;
|
|
const toId = banker.id;
|
|
if (fromId === toId) {
|
|
return snapshot;
|
|
}
|
|
|
|
const players = snapshot.players
|
|
.filter((player) => player.id !== toId)
|
|
.map((player) => {
|
|
if (player.id !== fromId) return player;
|
|
return {
|
|
...player,
|
|
id: toId,
|
|
name: banker.name,
|
|
role: "banker" as const,
|
|
connected: true,
|
|
isDummy: false,
|
|
lastActiveAt: now(),
|
|
};
|
|
});
|
|
|
|
if (!players.some((player) => player.id === toId)) {
|
|
players.push({
|
|
...banker,
|
|
role: "banker",
|
|
connected: true,
|
|
isDummy: false,
|
|
lastActiveAt: now(),
|
|
});
|
|
}
|
|
|
|
const transactions = snapshot.transactions.map((tx) => ({
|
|
...tx,
|
|
fromId: tx.fromId ? remapPlayerId(tx.fromId, fromId, toId) : null,
|
|
toId: tx.toId ? remapPlayerId(tx.toId, fromId, toId) : null,
|
|
}));
|
|
|
|
const chats = snapshot.chats.map((message) => ({
|
|
...message,
|
|
fromId: remapPlayerId(message.fromId, fromId, toId),
|
|
}));
|
|
|
|
const groups = snapshot.groups.map((group) => ({
|
|
...group,
|
|
createdBy: remapPlayerId(group.createdBy, fromId, toId),
|
|
memberIds: group.memberIds.map((id) => remapPlayerId(id, fromId, toId)),
|
|
}));
|
|
|
|
const takeoverRequests = snapshot.takeoverRequests.map((request) => ({
|
|
...request,
|
|
dummyId: remapPlayerId(request.dummyId, fromId, toId),
|
|
requesterId: remapPlayerId(request.requesterId, fromId, toId),
|
|
}));
|
|
|
|
return {
|
|
...snapshot,
|
|
bankerId: toId,
|
|
players,
|
|
transactions,
|
|
chats,
|
|
groups,
|
|
takeoverRequests,
|
|
};
|
|
}
|
|
|
|
export function applySnapshot(session: Session, snapshot: SessionSnapshot): void {
|
|
const currentBanker = session.players.get(session.bankerId) ?? null;
|
|
const normalized = currentBanker ? remapSnapshotBanker(snapshot, currentBanker) : snapshot;
|
|
|
|
session.status = normalized.status;
|
|
session.createdAt = normalized.createdAt;
|
|
session.bankerId = currentBanker ? currentBanker.id : normalized.bankerId;
|
|
session.blackoutActive = normalized.blackoutActive;
|
|
session.blackoutReason = normalized.blackoutReason;
|
|
|
|
const players = new Map<string, Player>();
|
|
normalized.players.forEach((player) => {
|
|
players.set(player.id, { ...player });
|
|
});
|
|
|
|
const banker = players.get(session.bankerId);
|
|
if (banker) {
|
|
banker.role = "banker";
|
|
banker.connected = true;
|
|
banker.isDummy = false;
|
|
banker.lastActiveAt = now();
|
|
}
|
|
|
|
players.forEach((player) => {
|
|
if (player.id !== session.bankerId && player.role === "banker") {
|
|
player.role = "player";
|
|
}
|
|
});
|
|
|
|
session.players = players;
|
|
session.transactions = normalized.transactions.map((tx) => ({ ...tx }));
|
|
session.chats = normalized.chats.map((chat) => ({ ...chat }));
|
|
session.groups = normalized.groups.map((group) => ({ ...group }));
|
|
session.takeoverRequests = normalized.takeoverRequests.map((request) => ({ ...request }));
|
|
}
|
|
|
|
export function isBlackoutActive(session: Session): boolean {
|
|
return session.blackoutActive;
|
|
}
|
|
|
|
export function joinSession(
|
|
session: Session,
|
|
name: string,
|
|
playerId?: string | null,
|
|
): Player {
|
|
const trimmedName = name.trim() || "Player";
|
|
if (playerId) {
|
|
const existing = session.players.get(playerId);
|
|
if (existing) {
|
|
existing.connected = true;
|
|
existing.isDummy = false;
|
|
existing.name = trimmedName;
|
|
existing.lastActiveAt = now();
|
|
return existing;
|
|
}
|
|
}
|
|
|
|
const id = createId();
|
|
const joinedAt = now();
|
|
const player: Player = {
|
|
id,
|
|
name: trimmedName,
|
|
role: "player",
|
|
balance: DEFAULT_START_BALANCE,
|
|
connected: true,
|
|
isDummy: false,
|
|
joinedAt,
|
|
lastActiveAt: joinedAt,
|
|
};
|
|
|
|
session.players.set(id, player);
|
|
return player;
|
|
}
|
|
|
|
export function startSession(session: Session, bankerId: string): void {
|
|
ensureBanker(session, bankerId);
|
|
if (session.status === "ended") {
|
|
throw new DomainError("Session has already ended");
|
|
}
|
|
session.status = "active";
|
|
}
|
|
|
|
export function endSession(session: Session, bankerId: string): void {
|
|
ensureBanker(session, bankerId);
|
|
session.status = "ended";
|
|
}
|
|
|
|
export function disconnectPlayer(session: Session, playerId: string): void {
|
|
const player = getPlayer(session, playerId);
|
|
player.connected = false;
|
|
player.lastActiveAt = now();
|
|
if (player.role !== "banker") {
|
|
player.isDummy = true;
|
|
}
|
|
}
|
|
|
|
export function setBlackout(
|
|
session: Session,
|
|
bankerId: string,
|
|
active: boolean,
|
|
reason?: string | null,
|
|
): void {
|
|
ensureOpenSession(session);
|
|
ensureBanker(session, bankerId);
|
|
session.blackoutActive = Boolean(active);
|
|
session.blackoutReason = active ? reason?.trim() || "EMP in effect" : null;
|
|
}
|
|
|
|
export function transfer(
|
|
session: Session,
|
|
fromId: string,
|
|
toId: string,
|
|
amountValue: unknown,
|
|
note?: string | null,
|
|
): Transaction {
|
|
ensureActiveSession(session);
|
|
ensureNotBlackout(session, fromId);
|
|
const amount = parseAmount(amountValue);
|
|
if (amount === null || amount <= 0) {
|
|
throw new DomainError("Transfer amount must be positive");
|
|
}
|
|
const from = getPlayer(session, fromId);
|
|
const to = getPlayer(session, toId);
|
|
if (from.role === "banker" || to.role === "banker") {
|
|
throw new DomainError("Banker does not hold a balance");
|
|
}
|
|
if (from.id === to.id) {
|
|
throw new DomainError("Cannot transfer to yourself");
|
|
}
|
|
if (from.balance < amount) {
|
|
throw new DomainError("Insufficient funds");
|
|
}
|
|
from.balance = clampBalance(from.balance - amount);
|
|
to.balance = clampBalance(to.balance + amount);
|
|
const transaction = createTransaction("transfer", from.id, to.id, amount, note, "player");
|
|
session.transactions.unshift(transaction);
|
|
return transaction;
|
|
}
|
|
|
|
export function bankerAdjust(
|
|
session: Session,
|
|
bankerId: string,
|
|
targetId: string,
|
|
amountValue: unknown,
|
|
note?: string | null,
|
|
): Transaction {
|
|
ensureActiveSession(session);
|
|
ensureNotBlackout(session, bankerId);
|
|
ensureBanker(session, bankerId);
|
|
const amount = parseAmount(amountValue);
|
|
if (amount === null || amount === 0) {
|
|
throw new DomainError("Adjustment amount must be non-zero");
|
|
}
|
|
const target = getPlayer(session, targetId);
|
|
if (target.role === "banker") {
|
|
throw new DomainError("Banker does not hold a balance");
|
|
}
|
|
target.balance = clampBalance(target.balance + amount);
|
|
const transaction = createTransaction(
|
|
"banker_adjust",
|
|
null,
|
|
target.id,
|
|
amount,
|
|
note,
|
|
"banker",
|
|
);
|
|
session.transactions.unshift(transaction);
|
|
return transaction;
|
|
}
|
|
|
|
export function bankerForceTransfer(
|
|
session: Session,
|
|
bankerId: string,
|
|
fromId: string,
|
|
toId: string,
|
|
amountValue: unknown,
|
|
note?: string | null,
|
|
): Transaction {
|
|
ensureActiveSession(session);
|
|
ensureNotBlackout(session, bankerId);
|
|
ensureBanker(session, bankerId);
|
|
const amount = parseAmount(amountValue);
|
|
if (amount === null || amount <= 0) {
|
|
throw new DomainError("Transfer amount must be positive");
|
|
}
|
|
const from = getPlayer(session, fromId);
|
|
const to = getPlayer(session, toId);
|
|
if (from.role === "banker" || to.role === "banker") {
|
|
throw new DomainError("Banker does not hold a balance");
|
|
}
|
|
if (from.id === to.id) {
|
|
throw new DomainError("Cannot transfer to the same player");
|
|
}
|
|
if (from.balance < amount) {
|
|
throw new DomainError("Source player lacks funds");
|
|
}
|
|
from.balance = clampBalance(from.balance - amount);
|
|
to.balance = clampBalance(to.balance + amount);
|
|
const transaction = createTransaction(
|
|
"banker_force_transfer",
|
|
from.id,
|
|
to.id,
|
|
amount,
|
|
note,
|
|
"banker",
|
|
);
|
|
session.transactions.unshift(transaction);
|
|
return transaction;
|
|
}
|
|
|
|
export function createDummyPlayer(
|
|
session: Session,
|
|
bankerId: string,
|
|
name: string,
|
|
balanceValue?: unknown,
|
|
): Player {
|
|
ensureOpenSession(session);
|
|
ensureBanker(session, bankerId);
|
|
const joinedAt = now();
|
|
const balance = parseAmount(balanceValue ?? DEFAULT_START_BALANCE) ?? DEFAULT_START_BALANCE;
|
|
const player: Player = {
|
|
id: createId(),
|
|
name: name.trim() || "Dummy",
|
|
role: "player",
|
|
balance: clampBalance(balance),
|
|
connected: false,
|
|
isDummy: true,
|
|
joinedAt,
|
|
lastActiveAt: joinedAt,
|
|
};
|
|
session.players.set(player.id, player);
|
|
return player;
|
|
}
|
|
|
|
export function addChatMessage(
|
|
session: Session,
|
|
playerId: string,
|
|
body: string,
|
|
groupId?: string | null,
|
|
): ChatMessage {
|
|
ensureOpenSession(session);
|
|
ensureNotBlackout(session, playerId);
|
|
const trimmed = body.trim();
|
|
if (!trimmed) {
|
|
throw new DomainError("Message cannot be empty");
|
|
}
|
|
if (trimmed.length > MAX_CHAT_LENGTH) {
|
|
throw new DomainError("Message is too long");
|
|
}
|
|
const sender = getPlayer(session, playerId);
|
|
const message: ChatMessage = {
|
|
id: createId(),
|
|
fromId: sender.id,
|
|
body: trimmed,
|
|
createdAt: now(),
|
|
groupId: groupId ?? null,
|
|
};
|
|
session.chats.unshift(message);
|
|
return message;
|
|
}
|
|
|
|
export function createChatGroup(
|
|
session: Session,
|
|
playerId: string,
|
|
name: string,
|
|
memberIds: string[],
|
|
): ChatGroup {
|
|
ensureOpenSession(session);
|
|
ensureNotBlackout(session, playerId);
|
|
getPlayer(session, playerId);
|
|
const trimmed = name.trim();
|
|
if (!trimmed) {
|
|
throw new DomainError("Group name is required");
|
|
}
|
|
const deduped = Array.from(new Set([playerId, ...memberIds]));
|
|
deduped.forEach((memberId) => getPlayer(session, memberId));
|
|
const group: ChatGroup = {
|
|
id: createId(),
|
|
name: trimmed,
|
|
memberIds: deduped,
|
|
createdAt: now(),
|
|
createdBy: playerId,
|
|
};
|
|
session.groups.unshift(group);
|
|
return group;
|
|
}
|
|
|
|
export function requestTakeover(
|
|
session: Session,
|
|
requesterId: string,
|
|
dummyId: string,
|
|
): TakeoverRequest {
|
|
ensureOpenSession(session);
|
|
const requester = getPlayer(session, requesterId);
|
|
const dummy = getPlayer(session, dummyId);
|
|
if (!dummy.isDummy) {
|
|
throw new DomainError("Selected player is not available for takeover");
|
|
}
|
|
if (requester.role === "banker") {
|
|
throw new DomainError("Banker cannot request takeover");
|
|
}
|
|
const existing = session.takeoverRequests.find(
|
|
(request) =>
|
|
request.dummyId === dummyId &&
|
|
request.requesterId === requesterId &&
|
|
request.status === "pending",
|
|
);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const request: TakeoverRequest = {
|
|
id: createId(),
|
|
dummyId,
|
|
requesterId,
|
|
createdAt: now(),
|
|
status: "pending",
|
|
};
|
|
session.takeoverRequests.unshift(request);
|
|
return request;
|
|
}
|
|
|
|
export function approveTakeover(
|
|
session: Session,
|
|
bankerId: string,
|
|
dummyId: string,
|
|
requesterId: string,
|
|
): string {
|
|
ensureOpenSession(session);
|
|
ensureBanker(session, bankerId);
|
|
const dummy = getPlayer(session, dummyId);
|
|
const requester = getPlayer(session, requesterId);
|
|
if (!dummy.isDummy) {
|
|
throw new DomainError("Selected player is not a dummy");
|
|
}
|
|
const targetRequest = session.takeoverRequests.find(
|
|
(request) =>
|
|
request.dummyId === dummyId &&
|
|
request.requesterId === requesterId &&
|
|
request.status === "pending",
|
|
);
|
|
if (!targetRequest) {
|
|
throw new DomainError("No takeover request found");
|
|
}
|
|
targetRequest.status = "approved";
|
|
// Reject any other pending requests for the same dummy.
|
|
session.takeoverRequests.forEach((request) => {
|
|
if (
|
|
request.dummyId === dummyId &&
|
|
request.status === "pending" &&
|
|
request.requesterId !== requesterId
|
|
) {
|
|
request.status = "rejected";
|
|
}
|
|
});
|
|
|
|
dummy.isDummy = false;
|
|
dummy.connected = requester.connected;
|
|
dummy.name = requester.name;
|
|
dummy.lastActiveAt = now();
|
|
session.players.delete(requesterId);
|
|
|
|
return dummy.id;
|
|
}
|
|
|
|
function ensureActiveSession(session: Session): void {
|
|
ensureOpenSession(session);
|
|
}
|
|
|
|
function ensureNotBlackout(session: Session, actorId?: string): void {
|
|
if (!isBlackoutActive(session)) {
|
|
return;
|
|
}
|
|
if (actorId) {
|
|
const actor = session.players.get(actorId);
|
|
if (actor?.role === "banker") {
|
|
return;
|
|
}
|
|
}
|
|
throw new DomainError("EMP in effect");
|
|
}
|
|
|
|
function ensureOpenSession(session: Session): void {
|
|
if (session.status === "ended") {
|
|
throw new DomainError("Session has ended");
|
|
}
|
|
}
|
|
|
|
function ensureBanker(session: Session, bankerId: string): void {
|
|
const banker = getPlayer(session, bankerId);
|
|
if (banker.role !== "banker") {
|
|
throw new DomainError("Only the banker can perform this action");
|
|
}
|
|
}
|
|
|
|
function getPlayer(session: Session, playerId: string): Player {
|
|
const player = session.players.get(playerId);
|
|
if (!player) {
|
|
throw new DomainError("Player not found");
|
|
}
|
|
return player;
|
|
}
|
|
|
|
function createTransaction(
|
|
kind: Transaction["kind"],
|
|
fromId: string | null,
|
|
toId: string | null,
|
|
amount: number,
|
|
note: string | null | undefined,
|
|
initiatedBy: Transaction["initiatedBy"],
|
|
): Transaction {
|
|
return {
|
|
id: createId(),
|
|
kind,
|
|
fromId,
|
|
toId,
|
|
amount: clampBalance(amount),
|
|
note: note?.trim() || null,
|
|
createdAt: now(),
|
|
initiatedBy,
|
|
};
|
|
}
|