Ctrl K
ComunidadGuías…

lib/capsule-pig-logic.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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,
};