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(); 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 requestTakeoverByToken( session: Session, dummyId: string, requesterName?: string | null, ): string { ensureOpenSession(session); const dummy = getPlayer(session, dummyId); if (!dummy.isDummy) { throw new DomainError("Selected player is not available for takeover"); } const token = createId(); const name = requesterName?.trim() || null; const request: TakeoverRequest = { id: createId(), dummyId, requesterId: token, requesterName: name, requesterToken: token, createdAt: now(), status: "pending", }; session.takeoverRequests.unshift(request); return token; } export function approveTakeover( session: Session, bankerId: string, dummyId: string, requesterId: string, ): string { ensureOpenSession(session); ensureBanker(session, bankerId); const dummy = getPlayer(session, dummyId); 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"); } const requester = session.players.get(requesterId); if (!requester && targetRequest.requesterToken) { targetRequest.status = "approved"; session.takeoverRequests.forEach((request) => { if ( request.dummyId === dummyId && request.status === "pending" && request.requesterId !== requesterId ) { request.status = "rejected"; } }); return dummy.id; } if (!requester) { throw new DomainError("Requester not 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; } export function claimTakeover(session: Session, requesterToken: string): string { ensureOpenSession(session); const targetRequest = session.takeoverRequests.find( (request) => request.requesterToken === requesterToken && request.status === "approved", ); if (!targetRequest) { throw new DomainError("Takeover not approved"); } const dummy = getPlayer(session, targetRequest.dummyId); if (!dummy.isDummy) { throw new DomainError("Dummy already taken"); } dummy.isDummy = false; dummy.connected = true; dummy.name = targetRequest.requesterName?.trim() || dummy.name; dummy.lastActiveAt = now(); session.takeoverRequests = session.takeoverRequests.filter( (request) => request.id !== targetRequest.id, ); 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, }; }