Prototype paused admin participant AI support
Enable an existing AI difficulty for a human seat through admin controls without granting private access or advancing paused play. Preserve custody, credentials, takeback and separate paced resume. Bind atomic writes and outgoing-grant revocation to live authority, room/pause versions and active seat access with durable exact receipts. Validation: types/lint, 5408 offline tests, build and 52 HTTP tests passed on source fingerprint 2157de0f4bb0b74d9760d52102601c52344cf674dee8ccd1d559f0a246a1d4ff; 22 authenticated admin HTTP groups, independent SQL/privacy/UI review, desktop/mobile and refresh checks. All 2098 baseline rooms and 4455 seats preserved. Migration 0015 is additive. Positive production admin QA awaits authorized access; full admin/rules scope remains unfinished.
This commit is contained in:
1 parent
137f9223f1
commit
2cddf03932
23 files changed
+3187
-10
No files matched your search
@@ -0,0 +1,208 @@
|
||||
import type { Decision, ResponseWindow } from './engine';
|
||||
import type { Card } from './cards';
|
||||
import { FACTIONS, PHASES } from './catalog';
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const count = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) < Number.MAX_SAFE_INTEGER;
|
||||
const text = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
|
||||
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(text);
|
||||
const counts = (value: unknown) => record(value) && Object.values(value).every(count);
|
||||
const sector = (value: unknown): value is number => count(value) && value >= 1 && value <= 18;
|
||||
const elites = (value: unknown) => record(value) &&
|
||||
['reserves','tanks','revived'].every(key => count(value[key])) && counts(value.forces);
|
||||
const cardKinds = { projectile:true, poison:true, lasgun:true, shield:true, snooper:true, poisonBlade:true, shieldSnooper:true,
|
||||
weirdingWay:true, chemistry:true, poisonTooth:true, artillery:true, worthless:true, hero:true, special:true } satisfies Record<Card['kind'], true>;
|
||||
const card = (value: unknown) => record(value) && text(value.id) && text(value.name) && typeof value.kind === 'string' && Object.hasOwn(cardKinds, value.kind) && (value.effect === undefined || typeof value.effect === 'string');
|
||||
const cards = (value: unknown) => Array.isArray(value) && value.every(card);
|
||||
const leader = (value: unknown) => record(value) && text(value.id) && text(value.name) && count(value.strength) &&
|
||||
FACTIONS.some(faction => faction.id === value.faction) && typeof value.dead === 'boolean' && count(value.deaths);
|
||||
const spiceCard = (value: unknown) => record(value) && (value.worm === true || value.sandtrout === true ||
|
||||
(text(value.territory) && count(value.amount) && sector(value.sector)));
|
||||
const spiceCards = (value: unknown) => Array.isArray(value) && value.every(spiceCard);
|
||||
const optionalCount = (value: unknown) => value === undefined || count(value);
|
||||
const nullableCount = (value: unknown) => value === null || count(value);
|
||||
|
||||
const decisions = {
|
||||
harassWithdraw: true,
|
||||
diplomatDefense: true,
|
||||
leaderSkillVisibility: true,
|
||||
leaderSkillRevival: true,
|
||||
homeworldRevivalDeployment: true,
|
||||
grummanCollection: true,
|
||||
caladanReinforcement: true,
|
||||
choamAudit: true,
|
||||
choamAuditPayment: true,
|
||||
strongholdCopy: true,
|
||||
richeseBlackMarket: true,
|
||||
richeseDeclaration: true,
|
||||
richeseCache: true,
|
||||
richeseUnbid: true,
|
||||
richeseAllyShipment: true,
|
||||
richeseAllyOpportunity: true,
|
||||
ecazAmbassador: true,
|
||||
moritaniTerror: true,
|
||||
moritaniSetup: true,
|
||||
moritaniPlacement: true,
|
||||
ecazPlacement: true,
|
||||
ecazSpice: true,
|
||||
choamStorm: true,
|
||||
choamMovement: true,
|
||||
choamMentat: true,
|
||||
choamFreeRevival: true,
|
||||
choamBattleFunding: true,
|
||||
choamMarket: true,
|
||||
choamTradeReply: true,
|
||||
choamTradeConfirm: true,
|
||||
ixSetup: true,
|
||||
ixAuction: true,
|
||||
ixTechnology: true,
|
||||
ixRicheseTechnology: true,
|
||||
ixAllyCard: true,
|
||||
mobileStronghold: true,
|
||||
ixSubstitution: true,
|
||||
revivalStop: true,
|
||||
faceDance: true,
|
||||
techToken: true,
|
||||
poisonTooth: true,
|
||||
stoneBurner: true,
|
||||
sukRescue: true,
|
||||
rihani: true,
|
||||
fullPlanOffer: true,
|
||||
bureaucratPayment: true,
|
||||
moritaniAssassinate: true,
|
||||
mentatQuestion: true,
|
||||
fullPlanRead: true,
|
||||
homeworldShipmentGuild: true,
|
||||
guildShipment: true,
|
||||
nullentropy: true,
|
||||
handExchange: true,
|
||||
battleLosses: true,
|
||||
homeworldDefense: true,
|
||||
homeworldExplosion: true,
|
||||
captureOffer: true,
|
||||
capturedLeader: true,
|
||||
guildTiming: true,
|
||||
auctionPayment: true,
|
||||
advisor: true,
|
||||
intrusion: true,
|
||||
advisorBattle: true,
|
||||
stormLosses: true,
|
||||
wormPlacement: true,
|
||||
wormProtection: true,
|
||||
discoveryDiscard: true,
|
||||
discoveryEntry: true,
|
||||
ecologicalStorm: true,
|
||||
greatMakerVote: true,
|
||||
greatMakerRide: true,
|
||||
wormRide: true,
|
||||
moritaniRetention: true,
|
||||
battleCards: true,
|
||||
} satisfies Record<Decision['kind'], true>;
|
||||
const responses = {
|
||||
moritaniPlacement: true,
|
||||
ecazPlacement: true,
|
||||
ecazCollection: true,
|
||||
moritaniAlliance: true,
|
||||
moritaniDuke: true,
|
||||
moritaniRetention: true,
|
||||
voice: true,
|
||||
prescience: true,
|
||||
nexusPrescience: true,
|
||||
advisor: true,
|
||||
emperorIncome: true,
|
||||
richesePurchaseIncome: true,
|
||||
richeseGift: true,
|
||||
richeseNoField: true,
|
||||
harkonnenBonus: true,
|
||||
harkonnenTraitor: true,
|
||||
guildIncome: true,
|
||||
emperorGift: true,
|
||||
emperorRevival: true,
|
||||
stormPeek: true,
|
||||
stormProtection: true,
|
||||
wormPlacement: true,
|
||||
wormSurvival: true,
|
||||
wormAllyProtection: true,
|
||||
atreidesAuction: true,
|
||||
atreidesSpice: true,
|
||||
guildTiming: true,
|
||||
kwisatz: true,
|
||||
capture: true,
|
||||
eliteStrength: true,
|
||||
fremenSupport: true,
|
||||
advisorFlip: true,
|
||||
nexusAdvisorFlip: true,
|
||||
nexusSardaukar: true,
|
||||
nexusGuildCunning: true,
|
||||
bgCharity: true,
|
||||
choamCharity: true,
|
||||
choamInflation: true,
|
||||
choamSale: true,
|
||||
choamWorthless: true,
|
||||
choamBattleIncome: true,
|
||||
choamAudit: true,
|
||||
choamBattleAid: true,
|
||||
worthlessKarama: true,
|
||||
faceDancerReplacement: true,
|
||||
choamRevival: true,
|
||||
revivalLimit: true,
|
||||
revivalDiscount: true,
|
||||
earlyRevival: true,
|
||||
revivalIncome: true,
|
||||
foreignGhola: true,
|
||||
ixSubstitution: true,
|
||||
ixMovement: true,
|
||||
fremenMovement: true,
|
||||
mobileStronghold: true,
|
||||
richeseAuction: true,
|
||||
richeseBlackMarket: true,
|
||||
ixAuction: true,
|
||||
ixTechnology: true,
|
||||
ixAllyCard: true,
|
||||
} satisfies Record<ResponseWindow['kind'], true>;
|
||||
|
||||
/** Bounded core/continuation shape check, not a complete save or rules certification.
|
||||
* Optional modules retain their own engine validators; do not assume fixed inventories. */
|
||||
export function adminSeatAiCoreShape(value: unknown): boolean {
|
||||
if (!record(value) || value.schema !== 1 || !count(value.turn) || value.turn < 1 || value.turn > 10 ||
|
||||
!count(value.phase) || value.phase >= PHASES.length || !sector(value.storm) ||
|
||||
!Array.isArray(value.players) || value.players.length < 1 || value.players.length > 6 ||
|
||||
(['setup', 'playing'].includes(String(value.status)) && value.players.length < 2)) return false;
|
||||
const players = value.players;
|
||||
if (!players.every(p => record(p) && text(p.id))) return false;
|
||||
const ids = new Set(players.map(p => p.id));
|
||||
const seated = (id: unknown) => typeof id === 'string' && ids.has(id);
|
||||
const seats = (list: unknown) => strings(list) && list.every(seated) && new Set(list).size === list.length;
|
||||
if (!seated(value.host) || !(value.active === null || seated(value.active)) || !seats(value.order) || !seats(value.ready) ||
|
||||
!(value.movementRemaining === null || seats(value.movementRemaining)) || !seats(value.stormDialers) ||
|
||||
!seats(value.hajr) || !strings(value.wormRides) || !seats(value.freeRevival) || !seats(value.winner) ||
|
||||
!strings(value.expansions) || !value.expansions.every(expansion => ['ix','choam','ecaz'].includes(expansion)) ||
|
||||
!cards(value.deck) || !cards(value.discard) || !counts(value.spice) || !counts(value.stormDials) ||
|
||||
!Array.isArray(value.spiceDeck) || !value.spiceDeck.every(spiceCard) ||
|
||||
!Array.isArray(value.spiceDiscard) || value.spiceDiscard.length !== 2 || !value.spiceDiscard.every(pile => Array.isArray(pile) && pile.every(spiceCard)) ||
|
||||
!['guildTimingGranted','guildTimingLocked','spicePeekKnown','stormCardKnown','nexus','shieldWallDestroyed'].every(key => typeof value[key] === 'boolean') ||
|
||||
!record(value.allianceOffers) || !Object.entries(value.allianceOffers).every(([owner, recipient]) => seated(owner) && seated(recipient)) ||
|
||||
!counts(value.emperorExtra) || !record(value.aid) ||
|
||||
!Object.entries(value.aid).every(([owner, offer]) => seated(owner) && record(offer) && seated(offer.recipient) && count(offer.amount))) return false;
|
||||
const auction = value.auction, storm = value.stormResolution, blow = value.spiceWindow;
|
||||
if (auction !== null && (!record(auction) || !cards(auction.cards) || !count(auction.index) || !count(auction.bid) ||
|
||||
!(auction.bidder === null || seated(auction.bidder)) || !seated(auction.active) || !seats(auction.passed) || !count(auction.opener) || !optionalCount(auction.allyPayment))) return false;
|
||||
if (!nullableCount(value.stormPending) || !nullableCount(value.stormCard) ||
|
||||
(storm !== null && (!record(storm) || !sector(storm.from) || !count(storm.distance) || !count(storm.traversed) || !strings(storm.pending)))) return false;
|
||||
if (blow !== null && (!record(blow) || !text(blow.territory) || !sector(blow.sector) || !count(blow.amount) ||
|
||||
typeof blow.harvested !== 'boolean' || !optionalCount(blow.harvesters))) return false;
|
||||
if (value.spiceResolution !== null && (!record(value.spiceResolution) || !spiceCards(value.spiceResolution.skipped))) return false;
|
||||
if (value.spiceSequence !== null && (!record(value.spiceSequence) || ![0, 1].includes(Number(value.spiceSequence.pile)) ||
|
||||
typeof value.spiceSequence.pile !== 'number' || !spiceCards(value.spiceSequence.skipped))) return false;
|
||||
if (value.karamaShipping !== null && (!record(value.karamaShipping) || !seated(value.karamaShipping.player) || !seated(value.karamaShipping.owner))) return false;
|
||||
if (value.pendingCapture !== null && (!record(value.pendingCapture) || !seated(value.pendingCapture.player) || !seated(value.pendingCapture.loser) || !text(value.pendingCapture.territory))) return false;
|
||||
if (!players.every(p => record(p) && ['spice','reserves','tanks','bribes','revived','revivalCycle','moved','battleLosses'].every(key => count(p[key])) &&
|
||||
['ready','leaderRevived','shipped'].every(key => typeof p[key] === 'boolean') && counts(p.forces) && cards(p.hand) &&
|
||||
Array.isArray(p.leaders) && p.leaders.every(leader) && strings(p.traitors) && strings(p.traitorChoices) &&
|
||||
(p.ally === null || seated(p.ally)) && (p.elites === undefined || elites(p.elites)))) return false;
|
||||
if (value.decision !== null && (!record(value.decision) || typeof value.decision.kind !== 'string' ||
|
||||
!Object.hasOwn(decisions, value.decision.kind) || !seated(value.decision.player))) return false;
|
||||
if (value.response !== null && (!record(value.response) || typeof value.response.kind !== 'string' ||
|
||||
!Object.hasOwn(responses, value.response.kind) || !seated(value.response.owner) || !seats(value.response.passed))) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { adminSeatAiCoreShape } from './admin-seat-ai-shape';
|
||||
import { applyAction, type Game } from './engine';
|
||||
import { FACTIONS } from './catalog';
|
||||
import { DIFFICULTIES, type Difficulty } from './bot-profiles';
|
||||
import type { AdminSeatAiView } from '../lib/admin-seat-ai';
|
||||
|
||||
export const seatAiUnavailable = 'This saved game needs inspection before changing participant control.';
|
||||
const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
const counter = (v: unknown) => Number.isSafeInteger(v) && Number(v) >= 0 && Number(v) < Number.MAX_SAFE_INTEGER;
|
||||
const difficulty = (v: unknown) => v === undefined || DIFFICULTIES.some(d => d === v);
|
||||
/** Guard the public envelope before projecting it; engine integrity guards run again on mutation. */
|
||||
export function seatAiGame(value: unknown, code: string): value is Game {
|
||||
if (!adminSeatAiCoreShape(value) || !record(value) || value.code !== code || !['lobby','setup','playing','finished'].includes(String(value.status)) ||
|
||||
typeof value.advanced !== 'boolean' || !counter(value.version) || !Array.isArray(value.players) || value.players.length < 1 || value.players.length > 6 ||
|
||||
!Array.isArray(value.log) || value.log.length > 250 || !value.log.every((entry, index, log) => record(entry) && counter(entry.seq) && Number(entry.seq) > 0 && typeof entry.text === 'string' && (index === 0 || Number(entry.seq) > Number(log[index - 1].seq))) ||
|
||||
(value.botsPending !== undefined && typeof value.botsPending !== 'boolean') || (value.botNextActionAt !== undefined && !counter(value.botNextActionAt))) return false;
|
||||
return value.players.every(p => record(p) && typeof p.id === 'string' && !!p.id.trim() && p.id.length <= 80 && typeof p.name === 'string' && p.name.length <= 80 &&
|
||||
FACTIONS.some(f => f.id === p.faction) && difficulty(p.bot) && difficulty(p.autopilot) && !(p.bot && p.autopilot)) &&
|
||||
new Set(value.players.map(p => p.id)).size === value.players.length && new Set(value.players.map(p => p.faction)).size === value.players.length;
|
||||
}
|
||||
export function projectAdminSeatAi(base: Omit<AdminSeatAiView, 'status'|'players'|'editable'|'blockedReason'>, value: unknown, activeHumans: Set<string>): AdminSeatAiView {
|
||||
if (!seatAiGame(value, base.code)) return { ...base, status: 'unreadable', players: [], editable: false, blockedReason: seatAiUnavailable };
|
||||
const blockedReason = base.removed ? 'Restore this removed room first.' : base.archived ? 'Unarchive and reopen this room first.' : base.closed ? 'Reopen this room first.' :
|
||||
!['setup','playing'].includes(value.status) ? 'Participant AI is available only after the game starts and before it ends.' : !base.paused ? 'Pause this room before changing participant control.' : null;
|
||||
return { ...base, status: value.status, blockedReason, editable: blockedReason === null, players: value.players.map(p => ({
|
||||
id: p.id, name: p.name, faction: p.faction, control: p.bot ? 'ai' : p.autopilot ? 'autopilot' : 'human', difficulty: p.bot ?? p.autopilot ?? null,
|
||||
eligible: !p.bot && !p.autopilot && activeHumans.has(p.id),
|
||||
})) };
|
||||
}
|
||||
/** Uses the ordinary legal controller change, without room-worker scheduling or private projection. */
|
||||
export function enableAdminSeatAi(game: Game, target: string, difficulty: Difficulty): Game {
|
||||
if (!seatAiGame(game, game.code)) throw new Error(seatAiUnavailable);
|
||||
const owner = game.players.find(p => p.id === target);
|
||||
if (!owner || owner.bot || owner.autopilot) throw new Error(seatAiUnavailable);
|
||||
const next = applyAction(game, target, { type: 'setAutopilot', difficulty });
|
||||
// setAutopilot appends one event, dropping the oldest at the 250-entry cap.
|
||||
const event = next.log.at(-1), seq = (game.log.at(-1)?.seq ?? 0) + 1;
|
||||
if (!event || event.seq !== seq || event.text !== `${owner.name} enabled ${difficulty} AI control for their seat.`) throw new Error(seatAiUnavailable);
|
||||
const expected = structuredClone(game);
|
||||
expected.players.find(p => p.id === target)!.autopilot = difficulty;
|
||||
expected.log.push({ seq, text: event.text }); if (expected.log.length > 250) expected.log.shift();
|
||||
if (JSON.stringify(next) !== JSON.stringify(expected)) throw new Error(seatAiUnavailable);
|
||||
event.text = `An administrator enabled ${difficulty} AI for ${owner.name}. The room remains paused; this player retains their seat and can take back control.`;
|
||||
return next;
|
||||
}
|
||||
Reference in new issue
Block a user