lib/capsule-pig-logic.js
Rama main · réplica de lectura del repositorio original.
// Server-side Logic for the Pig validation Capsule.
//
// Logic is the single source of truth. Every state change in the game happens
// through one of these functions and nowhere else: the die is rolled here, the
// turn rules are enforced here, and the game record lives in the Capsule's own
// datastore. A human button click and a Mate clay_tool_act call arrive at the
// same act pipeline with the same rules, so neither party can break a rule the
// other is bound by, and neither sees state the other cannot.
//
// The two seats are "user" and "mate". The server resolves its own actor before
// calling in, and the human actor maps onto the user seat; seat is never read
// from caller-supplied text.
//
// Rules: seats alternate turns. On your turn you may roll as often as you like;
// each roll adds to the turn total, but rolling a 1 loses the whole turn total
// and passes play. Holding banks the turn total into your score, and the first
// seat to bank at least 100 points wins. A finished game is frozen until
// someone resets it; only the user may reset a game in progress, because the
// Capsule belongs to the human, not to the Mate.
var crypto = require("crypto");
var GAME_DOC_ID = "game";
var TARGET_SCORE = 100;
var BUST_FACE = 1;
var USER = "user";
var MATE = "mate";
var SEATS = [USER, MATE];
var SEAT_LABELS = { user: "You", mate: "Your Mate" };
var MAX_RECENT_ROLLS = 12;
// Every read-modify-write for one stored game runs to completion before the
// next one starts. Two acts that arrive together would otherwise both validate
// against the same turn, and the second write would silently discard the first.
var gameLocks = Object.create(null);
function runExclusive(key, operation) {
var previous = gameLocks[key] || Promise.resolve();
var settled = previous.then(operation, operation);
gameLocks[key] = settled.then(function () {}, function () {});
return settled;
}
function secureRollDie() {
return crypto.randomInt(1, 7);
}
function otherSeat(seat) {
return seat === USER ? MATE : USER;
}
function newGame() {
return {
status: "playing",
turn: USER,
scores: { user: 0, mate: 0 },
turnTotal: 0,
lastRoll: null,
lastActor: null,
winner: null,
target: TARGET_SCORE,
sequence: 0,
eventSeq: 0,
recentRolls: [],
};
}
function safeScore(value) {
return Number.isInteger(value) && value >= 0 && value <= 10000 ? value : 0;
}
// Counters are ordering, not points: they only ever grow, so they carry no
// upper bound. eventSeq in particular must survive a reset, because a Display
// that saw event N must treat anything at or below N as already rendered.
function safeCounter(value) {
return Number.isInteger(value) && value >= 0 ? value : 0;
}
// A stored game is data, not a promise about shape. Anything unreadable is
// replaced by a fresh game rather than trusted.
function normalizeState(stored) {
if (!stored || typeof stored !== "object" || Array.isArray(stored)) return newGame();
if (SEATS.indexOf(stored.turn) === -1) return newGame();
if (stored.status !== "playing" && stored.status !== "complete") return newGame();
var scores = stored.scores && typeof stored.scores === "object" ? stored.scores : {};
var rolls = Array.isArray(stored.recentRolls) ? stored.recentRolls.slice(-MAX_RECENT_ROLLS) : [];
return {
status: stored.status,
turn: stored.turn,
scores: { user: safeScore(scores.user), mate: safeScore(scores.mate) },
turnTotal: safeScore(stored.turnTotal),
lastRoll: Number.isInteger(stored.lastRoll) ? stored.lastRoll : null,
lastActor: SEATS.indexOf(stored.lastActor) !== -1 ? stored.lastActor : null,
winner: SEATS.indexOf(stored.winner) !== -1 ? stored.winner : null,
target: TARGET_SCORE,
sequence: safeCounter(stored.sequence),
eventSeq: safeCounter(stored.eventSeq),
recentRolls: rolls.filter(function (entry) {
return entry && typeof entry.text === "string" && Number.isInteger(entry.face);
}),
};
}
// recentRolls holds rolls and only rolls, including a bust. Banking and winning
// are turn outcomes rather than rolls, so they never appear here.
function appendRoll(state, seat, face, text) {
var next = state.sequence + 1;
state.sequence = next;
state.recentRolls = state.recentRolls
.concat([{ id: "roll-" + next, seat: seat, face: face, text: text }])
.slice(-MAX_RECENT_ROLLS);
}
function requirePlayableTurn(state, seat) {
if (state.status !== "playing") {
throw new Error("This game is already over. Reset it to play again.");
}
if (state.turn !== seat) {
throw new Error("This move is out of turn: " + SEAT_LABELS[state.turn] + " to play.");
}
}
function roll(state, seat, rollDie) {
requirePlayableTurn(state, seat);
var face = rollDie();
if (!Number.isInteger(face) || face < 1 || face > 6) throw new Error("The die produced an invalid face.");
state.lastRoll = face;
state.lastActor = seat;
if (face === BUST_FACE) {
appendRoll(state, seat, face, SEAT_LABELS[seat] + " rolled a 1 and lost " + state.turnTotal + " point(s).");
state.turnTotal = 0;
state.turn = otherSeat(seat);
return state;
}
state.turnTotal += face;
appendRoll(state, seat, face, SEAT_LABELS[seat] + " rolled a " + face + " for a turn total of " + state.turnTotal + ".");
return state;
}
function hold(state, seat) {
requirePlayableTurn(state, seat);
var banked = state.turnTotal;
state.scores[seat] += banked;
state.lastActor = seat;
state.turnTotal = 0;
if (state.scores[seat] >= state.target) {
state.status = "complete";
state.winner = seat;
return state;
}
state.turn = otherSeat(seat);
return state;
}
function reset(state, seat) {
if (state.status === "playing" && seat !== USER) {
throw new Error("Only the user may reset a game that is still in progress.");
}
return newGame();
}
function statusTextFor(state, complete, userTurn) {
if (complete) {
return state.winner === USER ? "You win this game!" : "Your Mate wins this game.";
}
if (userTurn) {
return state.turnTotal > 0
? "Your turn: roll again or hold to bank " + state.turnTotal + " point(s)."
: "Your turn: roll the die.";
}
return "Your Mate's turn. Moves appear here as they happen.";
}
// The projection a Display renders and a Mate reads. It adds no meaning that is
// absent from state: every derived field is a restatement of the fields above.
// The score series exist because a progress chart binds to a collection.
function project(state) {
var complete = state.status === "complete";
var userTurn = !complete && state.turn === USER;
return {
status: state.status,
turn: state.turn,
scores: { user: state.scores.user, mate: state.scores.mate },
userScoreSeries: [{ seat: SEAT_LABELS.user, value: state.scores.user }],
mateScoreSeries: [{ seat: SEAT_LABELS.mate, value: state.scores.mate }],
turnTotal: state.turnTotal,
turnTotalSeries: [{ label: "Turn total", value: state.turnTotal }],
turnTotalText: "Turn total " + state.turnTotal + " of " + state.target + ", " + SEAT_LABELS[state.turn] + " to play.",
statusText: statusTextFor(state, complete, userTurn),
lastRollText: state.lastRoll === null
? "No rolls yet."
: "Last roll: " + state.lastRoll + " by " + SEAT_LABELS[state.lastActor || state.turn] + ".",
lastRoll: state.lastRoll,
lastActor: state.lastActor,
winner: state.winner,
target: state.target,
recentRolls: state.recentRolls,
complete: complete,
userTurn: userTurn,
eventSeq: state.eventSeq,
};
}
// When the Mate should be engaged is a game rule, so Logic declares it on the
// event rather than the host inferring it from Pig's fields. The host bridge
// delivers any declared engagement generically for every Capsule: "turn"
// means play your seat now, "start" means the human opened a fresh game and
// you should acknowledge it without acting.
function engagementFor(seat, actionId, next) {
if (seat !== USER) return null;
if (next.status === "playing" && next.turn === MATE) return { kind: "turn" };
if (actionId === "reset" && next.status === "playing") return { kind: "start" };
return null;
}
// Causality for the live Display: {actor, action, previous, next} plus a
// monotonic seq. Sending only the new state would make a Mate's move teleport
// onto the human's screen; the event lets the Display replay and attribute it.
function buildEvent(seat, actionId, previous, next) {
var event = {
seq: next.eventSeq,
actor: seat,
action: actionId,
previous: previous,
next: next,
};
var engage = engagementFor(seat, actionId, next);
if (engage) event.engage = engage;
return event;
}
function seatFor(context) {
var actor = context && context.actor;
if (actor === "human" || actor === USER) return USER;
if (actor === MATE) return MATE;
throw new Error("The Capsule caller seat could not be determined.");
}
// One act pipeline. The human surface and the Mate MCP surface both land here.
function createRuntime(options) {
options = options || {};
var storage = options.storage;
var rollDie = typeof options.rollDie === "function" ? options.rollDie : secureRollDie;
var lockKey = options.lockKey || "pig";
if (!storage) throw new Error("The Pig Capsule requires its datastore.");
async function writeState(state) {
await storage.put({ _id: GAME_DOC_ID, state: state });
return state;
}
async function readState() {
var doc = await storage.get(GAME_DOC_ID);
if (!doc) return writeState(newGame());
return normalizeState(doc.state);
}
async function snapshot(context) {
seatFor(context);
return runExclusive(lockKey, async function () {
return project(await readState());
});
}
// An act returns {state, event}: the new projection for the caller and the
// causal event for every watching Display. Both are built inside the lock,
// so seq order on the wire is the order the rules actually ran in.
async function act(context, actionId, args) {
var seat = seatFor(context);
return runExclusive(lockKey, async function () {
var state = await readState();
var previous = project(state);
var next;
if (actionId === "roll") next = roll(state, seat, rollDie);
else if (actionId === "hold") next = hold(state, seat);
else if (actionId === "reset") next = reset(state, seat);
else throw new Error("Unknown Pig action '" + String(actionId) + "'.");
next.eventSeq = previous.eventSeq + 1;
await writeState(next);
var projected = project(next);
return { state: projected, event: buildEvent(seat, actionId, previous, projected) };
});
}
return { snapshot: snapshot, act: act };
}
module.exports = {
GAME_DOC_ID: GAME_DOC_ID,
TARGET_SCORE: TARGET_SCORE,
SEATS: SEATS,
newGame: newGame,
normalizeState: normalizeState,
project: project,
buildEvent: buildEvent,
roll: roll,
hold: hold,
reset: reset,
createRuntime: createRuntime,
};