Loading...
Loading...
Game state management for turn-based board games. Use when designing state structure, implementing game logic, validating actions, managing phases/turns, or handling complex game rules. Covers reducers, state machines, and undo/redo.
npx skill4agent add fil512/upship game-state// BAD: Mutating state
function addMoney(state, playerId, amount) {
state.players[playerId].money += amount;
return state;
}
// GOOD: Creating new state
function addMoney(state, playerId, amount) {
return {
...state,
players: {
...state.players,
[playerId]: {
...state.players[playerId],
money: state.players[playerId].money + amount
}
}
};
}const gameState = {
// Metadata
id: 'game-123',
version: 42,
phase: 'worker-placement',
currentPlayer: 'player-1',
turnNumber: 5,
age: 2,
// Player states
players: {
'player-1': { /* player state */ },
'player-2': { /* player state */ }
},
// Shared state
board: { /* board state */ },
market: { /* market state */ },
decks: { /* deck state */ }
};// BAD: Deeply nested
const state = {
players: [{
ships: [{
upgrades: [{ type: 'engine', stats: {...} }]
}]
}]
};
// GOOD: Normalized with references
const state = {
players: {
'p1': { id: 'p1', shipIds: ['ship-1', 'ship-2'] }
},
ships: {
'ship-1': { id: 'ship-1', ownerId: 'p1', upgradeIds: ['upg-1'] }
},
upgrades: {
'upg-1': { id: 'upg-1', type: 'engine', stats: {...} }
}
};// BAD: Storing computed values
const playerState = {
money: 100,
income: 15,
totalMoney: 115 // Don't store this!
};
// GOOD: Compute when needed
function getTotalMoney(player) {
return player.money + player.income;
}
// Or use selectors
const selectors = {
totalLift: (state, playerId) => {
const player = state.players[playerId];
return player.gasCubes * 5;
},
canLaunch: (state, playerId) => {
const lift = selectors.totalLift(state, playerId);
const weight = selectors.totalWeight(state, playerId);
return lift >= weight;
}
};// Actions describe what happened
const action = {
type: 'PLACE_WORKER',
playerId: 'player-1',
payload: {
workerId: 'agent-1',
locationId: 'construction-hall'
}
};
// Action creators for consistency
const actions = {
placeWorker: (playerId, workerId, locationId) => ({
type: 'PLACE_WORKER',
playerId,
payload: { workerId, locationId }
}),
acquireTechnology: (playerId, techId, cost) => ({
type: 'ACQUIRE_TECHNOLOGY',
playerId,
payload: { techId, cost }
})
};function gameReducer(state, action) {
switch (action.type) {
case 'PLACE_WORKER':
return placeWorkerReducer(state, action);
case 'ACQUIRE_TECHNOLOGY':
return acquireTechnologyReducer(state, action);
case 'LAUNCH_SHIP':
return launchShipReducer(state, action);
default:
return state;
}
}
function placeWorkerReducer(state, action) {
const { playerId, payload: { workerId, locationId } } = action;
return {
...state,
workers: {
...state.workers,
[workerId]: {
...state.workers[workerId],
locationId,
available: false
}
},
locations: {
...state.locations,
[locationId]: {
...state.locations[locationId],
workerIds: [...state.locations[locationId].workerIds, workerId]
}
}
};
}function validateAction(state, action) {
const validator = validators[action.type];
if (!validator) {
return { valid: false, reason: 'Unknown action type' };
}
return validator(state, action);
}
const validators = {
PLACE_WORKER: (state, action) => {
const { playerId, payload: { workerId, locationId } } = action;
// Check it's player's turn
if (state.currentPlayer !== playerId) {
return { valid: false, reason: 'Not your turn' };
}
// Check worker belongs to player and is available
const worker = state.workers[workerId];
if (!worker || worker.ownerId !== playerId) {
return { valid: false, reason: 'Invalid worker' };
}
if (!worker.available) {
return { valid: false, reason: 'Worker already placed' };
}
// Check location exists and has space
const location = state.locations[locationId];
if (!location) {
return { valid: false, reason: 'Invalid location' };
}
if (location.workerIds.length >= location.capacity) {
return { valid: false, reason: 'Location is full' };
}
return { valid: true };
}
};async function processAction(gameId, playerId, action) {
// 1. Load current state
const state = await loadGameState(gameId);
// 2. Validate
const validation = validateAction(state, action);
if (!validation.valid) {
return { success: false, error: validation.reason };
}
// 3. Apply action
let newState = gameReducer(state, action);
// 4. Check for triggered effects
newState = processTriggers(newState, action);
// 5. Check for phase transitions
newState = checkPhaseTransition(newState);
// 6. Increment version
newState = { ...newState, version: newState.version + 1 };
// 7. Persist
await saveGameState(gameId, newState);
return { success: true, newState };
}const phases = {
SETUP: 'setup',
WORKER_PLACEMENT: 'worker-placement',
REVEAL: 'reveal',
LAUNCH: 'launch',
INCOME: 'income',
CLEANUP: 'cleanup',
AGE_TRANSITION: 'age-transition',
GAME_END: 'game-end'
};
const phaseTransitions = {
[phases.SETUP]: {
next: phases.WORKER_PLACEMENT,
canTransition: (state) => allPlayersReady(state)
},
[phases.WORKER_PLACEMENT]: {
next: phases.REVEAL,
canTransition: (state) => allWorkersPlaced(state)
},
[phases.REVEAL]: {
next: phases.LAUNCH,
canTransition: (state) => allCardsRevealed(state)
},
// ... etc
};
function checkPhaseTransition(state) {
const currentPhase = phaseTransitions[state.phase];
if (currentPhase && currentPhase.canTransition(state)) {
return transitionToPhase(state, currentPhase.next);
}
return state;
}function getNextPlayer(state) {
const playerOrder = state.playerOrder;
const currentIndex = playerOrder.indexOf(state.currentPlayer);
const nextIndex = (currentIndex + 1) % playerOrder.length;
return playerOrder[nextIndex];
}
function advanceTurn(state) {
const nextPlayer = getNextPlayer(state);
const isNewRound = nextPlayer === state.playerOrder[0];
return {
...state,
currentPlayer: nextPlayer,
turnNumber: isNewRound ? state.turnNumber + 1 : state.turnNumber
};
}function calculateShipStats(state, playerId) {
const player = state.players[playerId];
const blueprint = state.blueprints[player.blueprintId];
// Start with baseline stats
let stats = { ...blueprint.baselineStats };
// Add stats from installed upgrades
for (const slotId of blueprint.slotIds) {
const slot = state.slots[slotId];
if (slot.upgradeId) {
const upgrade = state.upgrades[slot.upgradeId];
stats = mergeStats(stats, upgrade.stats);
}
}
// Calculate lift from gas cubes
const gasCubes = countGasCubes(state, playerId);
stats.lift = gasCubes * 5;
return stats;
}
function canLaunch(state, playerId) {
const stats = calculateShipStats(state, playerId);
// Physics check: Lift >= Weight
if (stats.lift < stats.weight) {
return { can: false, reason: 'Insufficient lift' };
}
// Must have pilot
const player = state.players[playerId];
if (player.pilots < 1) {
return { can: false, reason: 'No pilot available' };
}
// Must have ship in hangar
if (player.launchHangar.length === 0) {
return { can: false, reason: 'No ships in hangar' };
}
return { can: true };
}function resolveHazardCheck(state, playerId, hazardCard) {
const shipStats = calculateShipStats(state, playerId);
const results = [];
for (const check of hazardCard.checks) {
const playerValue = shipStats[check.stat];
const passed = playerValue >= check.difficulty;
results.push({
stat: check.stat,
required: check.difficulty,
actual: playerValue,
passed
});
}
const allPassed = results.every(r => r.passed);
return {
hazardCard,
results,
outcome: allPassed ? 'success' : hazardCard.failureEffect
};
}const gameWithHistory = {
...gameState,
history: {
past: [], // Previous states
future: [] // States after undo (for redo)
}
};
function applyActionWithHistory(state, action) {
// Save current state to history
const newPast = [...state.history.past, state];
// Apply action
const newState = gameReducer(state, action);
return {
...newState,
history: {
past: newPast,
future: [] // Clear redo stack on new action
}
};
}
function undo(state) {
if (state.history.past.length === 0) return state;
const previous = state.history.past[state.history.past.length - 1];
const newPast = state.history.past.slice(0, -1);
return {
...previous,
history: {
past: newPast,
future: [state, ...state.history.future]
}
};
}describe('placeWorkerReducer', () => {
it('should place worker at location', () => {
const state = createTestState();
const action = actions.placeWorker('p1', 'worker-1', 'location-a');
const newState = gameReducer(state, action);
expect(newState.workers['worker-1'].locationId).toBe('location-a');
expect(newState.locations['location-a'].workerIds).toContain('worker-1');
});
it('should not mutate original state', () => {
const state = createTestState();
const original = JSON.stringify(state);
const action = actions.placeWorker('p1', 'worker-1', 'location-a');
gameReducer(state, action);
expect(JSON.stringify(state)).toBe(original);
});
});describe('full game round', () => {
it('should complete worker placement phase', () => {
let state = createGameState({ playerCount: 2 });
// Each player places 3 workers
for (let i = 0; i < 6; i++) {
const playerId = state.currentPlayer;
const worker = getAvailableWorker(state, playerId);
const location = getValidLocation(state);
state = processAction(state, actions.placeWorker(
playerId, worker.id, location.id
));
}
expect(state.phase).toBe('reveal');
});
});