CoompanionApp/server/domain.ts

593 lines
16 KiB
TypeScript
Raw Normal View History

2026-02-03 13:48:56 +01:00
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;
}
2026-02-03 16:35:01 +01:00
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;
}
2026-02-03 13:48:56 +01:00
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");
}
2026-02-03 16:35:01 +01:00
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");
}
2026-02-03 13:48:56 +01:00
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;
}
2026-02-03 16:35:01 +01:00
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;
}
2026-02-03 13:48:56 +01:00
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,
};
}