Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions shared/state-defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { describe, it, expect } from 'bun:test'
import { GAME_STATE_DEFAULTS, createDefaultGameState, migrateGameState, validateGameState } from './state-defaults'
import { DEFAULT_CONFIG } from './types'
import { DEFAULT_CONFIG, DEFAULT_DIFFICULTY } from './types'

describe('GAME_STATE_DEFAULTS', () => {
it('has no undefined values', () => {
Expand All @@ -29,6 +29,8 @@ describe('GAME_STATE_DEFAULTS', () => {
'wipeTicksRemaining',
'wipeWaveNumber',
'alienShootingDisabled',
'nextEntityId',
'difficulty',
'config',
]

Expand Down Expand Up @@ -142,6 +144,43 @@ describe('migrateGameState', () => {
expect(issues).toEqual([])
})

it('derives nextEntityId from existing e_<n> entity ids when missing', () => {
// Simulate old persisted state from before nextEntityId moved into GameState
const oldState = {
roomCode: 'OLD02',
entities: [
{ kind: 'barrier', id: 'e_3', x: 10, segments: [] },
{ kind: 'alien', id: 'e_42', x: 20, y: 5, type: 'octopus', alive: true, row: 0, col: 0, points: 10 },
{ kind: 'bullet', id: 'b_100_p1', x: 30, y: 10, ownerId: 'p1', dy: -1 }, // Non-e_ id ignored
],
// NOTE: nextEntityId is MISSING
}

const migrated = migrateGameState(oldState as any)

expect(migrated.nextEntityId).toBe(43) // max e_<n> + 1, never collides
})

it('preserves persisted nextEntityId when present', () => {
const migrated = migrateGameState({ roomCode: 'NEW01', nextEntityId: 77 } as any)
expect(migrated.nextEntityId).toBe(77)
})

it('defaults difficulty to DEFAULT_DIFFICULTY for old persisted states', () => {
// Old persisted states predate the difficulty snapshot
const migrated = migrateGameState({ roomCode: 'OLD03' } as any)
expect(migrated.difficulty).toEqual(DEFAULT_DIFFICULTY)
expect(migrated.difficulty).not.toBe(DEFAULT_DIFFICULTY) // cloned, not shared
})

it('preserves a persisted difficulty snapshot', () => {
const custom = structuredClone(DEFAULT_DIFFICULTY)
custom.name = 'flatter-multi-A'
custom.waveRamp.speedPctPerWave = 0.05
const migrated = migrateGameState({ roomCode: 'NEW02', difficulty: custom } as any)
expect(migrated.difficulty).toBe(custom)
})

it('merges config with defaults', () => {
const partialConfig = {
roomCode: 'CONF',
Expand Down Expand Up @@ -188,10 +227,10 @@ describe('validateGameState', () => {
expect(validateGameState(123)).toEqual(['State is not an object'])
})

it('checks all 18 required fields', () => {
it('checks all 20 required fields', () => {
const issues = validateGameState({})
// Should have 18 missing field errors (including roomCode and maxLives)
expect(issues.filter((i) => i.includes('Missing field')).length).toBe(18)
// Should have 20 missing field errors (including roomCode and maxLives)
expect(issues.filter((i) => i.includes('Missing field')).length).toBe(20)
})
})

Expand Down
26 changes: 24 additions & 2 deletions shared/state-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// 3. Run tests - the type-level check will fail if coverage is incomplete
// 4. Never add field initialization to startGame(), nextWave(), or other methods

import { DEFAULT_CONFIG, type GameState, type GameStatus } from './types'
import { DEFAULT_CONFIG, DEFAULT_DIFFICULTY, type GameState, type GameStatus } from './types'

// ─── Status Registry ─────────────────────────────────────────────────────────
// Single source of truth for all GameStatus values.
Expand Down Expand Up @@ -79,6 +79,8 @@ export const GAME_STATE_DEFAULTS: Omit<GameState, 'roomCode'> = {
wipeTicksRemaining: null,
wipeWaveNumber: null,
alienShootingDisabled: false, // Set to true to disable alien shooting for debugging
nextEntityId: 1,
difficulty: DEFAULT_DIFFICULTY,
config: DEFAULT_CONFIG,
}

Expand All @@ -103,6 +105,7 @@ export function createDefaultGameState(roomCode: string): GameState {
players: {},
readyPlayerIds: [],
entities: [],
difficulty: structuredClone(DEFAULT_DIFFICULTY),
config: { ...DEFAULT_CONFIG },
}
}
Expand All @@ -113,15 +116,32 @@ export function createDefaultGameState(roomCode: string): GameState {
* Existing values in persistedState are preserved.
*/
export function migrateGameState(persistedState: Partial<GameState> & { roomCode: string }): GameState {
return {
const migrated: GameState = {
...GAME_STATE_DEFAULTS,
...persistedState,
// Old persisted states predate the difficulty snapshot — default them to
// the shipped config (clone so no room shares the module constant).
difficulty: persistedState.difficulty ?? structuredClone(DEFAULT_DIFFICULTY),
// Ensure config doesn't lose new fields
config: {
...DEFAULT_CONFIG,
...(persistedState.config ?? {}),
},
}

// nextEntityId used to live in Durable Object instance state, so older
// persisted states don't carry it. Derive a non-colliding default from any
// existing `e_<n>` entity ids so rehydrated rooms never reuse an id.
if (persistedState.nextEntityId === undefined) {
let maxEntityId = 0
for (const entity of persistedState.entities ?? []) {
const match = /^e_(\d+)$/.exec(entity.id)
if (match) maxEntityId = Math.max(maxEntityId, Number(match[1]))
}
migrated.nextEntityId = maxEntityId + 1
}

return migrated
}

/**
Expand Down Expand Up @@ -157,6 +177,8 @@ export function validateGameState(state: unknown): string[] {
'wipeTicksRemaining',
'wipeWaveNumber',
'alienShootingDisabled',
'nextEntityId',
'difficulty',
'config',
]

Expand Down
104 changes: 104 additions & 0 deletions shared/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
seededRandom,
createBarrierSegments,
createAlienFormation,
validateDifficultyConfig,
DEFAULT_DIFFICULTY,
type Entity,
type AlienEntity,
type BulletEntity,
Expand Down Expand Up @@ -655,3 +657,105 @@ describe('applyPlayerInput properties', () => {
)
})
})

// ─── validateDifficultyConfig Tests ──────────────────────────────────────────

describe('validateDifficultyConfig', () => {
test('DEFAULT_DIFFICULTY is valid', () => {
expect(validateDifficultyConfig(DEFAULT_DIFFICULTY)).toEqual([])
})

test('DEFAULT_DIFFICULTY survives a JSON round-trip and stays valid', () => {
expect(validateDifficultyConfig(JSON.parse(JSON.stringify(DEFAULT_DIFFICULTY)))).toEqual([])
})

test('rejects non-object input', () => {
expect(validateDifficultyConfig(null)).toEqual(['Config is not an object'])
expect(validateDifficultyConfig(undefined)).toEqual(['Config is not an object'])
expect(validateDifficultyConfig('ship-v1')).toEqual(['Config is not an object'])
expect(validateDifficultyConfig(42)).toEqual(['Config is not an object'])
expect(validateDifficultyConfig([])).toEqual(['Config is not an object'])
})

test('rejects missing or empty name', () => {
const config = structuredClone(DEFAULT_DIFFICULTY) as Record<string, unknown>
config.name = ''
expect(validateDifficultyConfig(config)).toContain('name must be a non-empty string')
delete config.name
expect(validateDifficultyConfig(config)).toContain('name must be a non-empty string')
})

test('rejects missing base block and bad base numbers', () => {
const noBase = structuredClone(DEFAULT_DIFFICULTY) as Record<string, unknown>
delete noBase.base
expect(validateDifficultyConfig(noBase)).toContain('base must be an object')

const badRate = structuredClone(DEFAULT_DIFFICULTY)
badRate.base.alienShootRate = Number.NaN
expect(validateDifficultyConfig(badRate)).toContain('base.alienShootRate must be a finite number >= 0')

const zeroInterval = structuredClone(DEFAULT_DIFFICULTY)
zeroInterval.base.alienMoveIntervalTicks = 0
expect(validateDifficultyConfig(zeroInterval)).toContain('base.alienMoveIntervalTicks must be a finite number > 0')
})

test('rejects when any of the four player counts is missing', () => {
for (const count of [1, 2, 3, 4] as const) {
const config = structuredClone(DEFAULT_DIFFICULTY)
delete (config.perPlayerCount as Record<number, unknown>)[count]
expect(validateDifficultyConfig(config)).toContain(`perPlayerCount.${count} is missing`)
}
})

test('rejects non-finite and non-positive per-player-count numbers', () => {
const negSpeed = structuredClone(DEFAULT_DIFFICULTY)
negSpeed.perPlayerCount[2].speedMult = -1
expect(validateDifficultyConfig(negSpeed)).toContain('perPlayerCount.2.speedMult must be a finite number > 0')

const infCols = structuredClone(DEFAULT_DIFFICULTY)
infCols.perPlayerCount[3].cols = Number.POSITIVE_INFINITY
expect(validateDifficultyConfig(infCols)).toContain('perPlayerCount.3.cols must be a finite number > 0')

const zeroLives = structuredClone(DEFAULT_DIFFICULTY)
zeroLives.perPlayerCount[4].lives = 0
expect(validateDifficultyConfig(zeroLives)).toContain('perPlayerCount.4.lives must be a finite number > 0')

const negBarriers = structuredClone(DEFAULT_DIFFICULTY)
negBarriers.perPlayerCount[1].barriers = -1
expect(validateDifficultyConfig(negBarriers)).toContain('perPlayerCount.1.barriers must be a finite number >= 0')
})

test('allows zero barriers (a legal, if brutal, config)', () => {
const config = structuredClone(DEFAULT_DIFFICULTY)
config.perPlayerCount[1].barriers = 0
expect(validateDifficultyConfig(config)).toEqual([])
})

test('rejects invalid livesMode', () => {
const config = structuredClone(DEFAULT_DIFFICULTY) as Record<string, unknown>
config.livesMode = 'infinite'
expect(validateDifficultyConfig(config)).toContain("livesMode must be 'shared' or 'per-player'")
})

test('rejects missing waveRamp block and bad ramp numbers', () => {
const noRamp = structuredClone(DEFAULT_DIFFICULTY) as Record<string, unknown>
delete noRamp.waveRamp
expect(validateDifficultyConfig(noRamp)).toContain('waveRamp must be an object')

const negPct = structuredClone(DEFAULT_DIFFICULTY)
negPct.waveRamp.shootPctPerWave = -0.1
expect(validateDifficultyConfig(negPct)).toContain('waveRamp.shootPctPerWave must be a finite number >= 0')

const zeroCap = structuredClone(DEFAULT_DIFFICULTY)
zeroCap.waveRamp.maxWaveForRamp = 0
expect(validateDifficultyConfig(zeroCap)).toContain('waveRamp.maxWaveForRamp must be a finite number > 0')
})

test('accumulates multiple issues for a thoroughly broken config', () => {
const issues = validateDifficultyConfig({ name: 'broken' })
expect(issues.length).toBeGreaterThanOrEqual(3)
expect(issues).toContain('base must be an object')
expect(issues).toContain('perPlayerCount must be an object')
expect(issues).toContain('waveRamp must be an object')
})
})
Loading
Loading