Skip to content
Merged
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
42 changes: 21 additions & 21 deletions src/input/surface-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,17 +147,17 @@ describe('findFoodPileAt', () => {
it('returns the pile at the exact tile coordinate', () => {
const world = makeWorld({
foodPiles: [
{ foodPileId: 1, tileX: 10, tileY: 20 },
{ foodPileId: 2, tileX: 30, tileY: 40 },
{ foodPileId: 3, tileX: 50, tileY: 60 },
{ foodPileId: 1, tileX: 10, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50},
{ foodPileId: 2, tileX: 30, tileY: 40 , pickupsRemaining: 50, pickupsInitial: 50},
{ foodPileId: 3, tileX: 50, tileY: 60 , pickupsRemaining: 50, pickupsInitial: 50},
],
});
expect(findFoodPileAt(world, 30, 40)).toEqual({ foodPileId: 2, tileX: 30, tileY: 40 });
expect(findFoodPileAt(world, 30, 40)).toEqual({ foodPileId: 2, tileX: 30, tileY: 40 , pickupsRemaining: 50, pickupsInitial: 50});
});

it('returns null when no pile is at the given tile', () => {
const world = makeWorld({
foodPiles: [{ foodPileId: 1, tileX: 10, tileY: 20 }],
foodPiles: [{ foodPileId: 1, tileX: 10, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50}],
});
expect(findFoodPileAt(world, 11, 20)).toBeNull();
});
Expand All @@ -170,8 +170,8 @@ describe('findFoodPileAt', () => {
it('returns first match when multiple piles share coords (edge case)', () => {
const world = makeWorld({
foodPiles: [
{ foodPileId: 1, tileX: 5, tileY: 5 },
{ foodPileId: 2, tileX: 5, tileY: 5 },
{ foodPileId: 1, tileX: 5, tileY: 5 , pickupsRemaining: 50, pickupsInitial: 50},
{ foodPileId: 2, tileX: 5, tileY: 5 , pickupsRemaining: 50, pickupsInitial: 50},
],
});
const result = findFoodPileAt(world, 5, 5);
Expand All @@ -187,7 +187,7 @@ describe('handleSurfaceLeftClick — food pile mark', () => {
it('pushes MarkFoodPileCommand for a pile at the clicked tile', () => {
const world = makeWorld({
tick: 5,
foodPiles: [{ foodPileId: 7, tileX: 10, tileY: 20 }],
foodPiles: [{ foodPileId: 7, tileX: 10, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50}],
});
const vs = makeViewState('surface', 64, 64);
const state = makeState();
Expand All @@ -203,7 +203,7 @@ describe('handleSurfaceLeftClick — food pile mark', () => {
});

it('pushes no command when no food pile exists at the clicked tile', () => {
const world = makeWorld({ foodPiles: [{ foodPileId: 1, tileX: 5, tileY: 5 }] });
const world = makeWorld({ foodPiles: [{ foodPileId: 1, tileX: 5, tileY: 5 , pickupsRemaining: 50, pickupsInitial: 50}] });
const vs = makeViewState('surface', 64, 64);
const state = makeState();
const { x, y } = tileToScreen(6, 5, 64, 64);
Expand All @@ -212,7 +212,7 @@ describe('handleSurfaceLeftClick — food pile mark', () => {
});

it('is a no-op when activeView is underground', () => {
const world = makeWorld({ foodPiles: [{ foodPileId: 1, tileX: 10, tileY: 20 }] });
const world = makeWorld({ foodPiles: [{ foodPileId: 1, tileX: 10, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50}] });
const vs = makeViewState('underground', 64, 64);
const state = makeState();
const { x, y } = tileToScreen(10, 20, 64, 64);
Expand All @@ -221,7 +221,7 @@ describe('handleSurfaceLeftClick — food pile mark', () => {
});

it('is a no-op when pointer is over HUD TRIANGLE zone', () => {
const world = makeWorld({ foodPiles: [{ foodPileId: 1, tileX: 10, tileY: 20 }] });
const world = makeWorld({ foodPiles: [{ foodPileId: 1, tileX: 10, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50}] });
const vs = makeViewState('surface', 64, 64);
const state = makeState();
// HUD.TRIANGLE zone — use a point guaranteed inside it.
Expand All @@ -233,7 +233,7 @@ describe('handleSurfaceLeftClick — food pile mark', () => {

it('is a no-op while panInputState.spaceHeld is true (Space+left-drag is pan, not world action)', () => {
const world = makeWorld({
foodPiles: [{ foodPileId: 7, tileX: 10, tileY: 20 }],
foodPiles: [{ foodPileId: 7, tileX: 10, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50}],
});
const vs = makeViewState('surface', 64, 64);
const state = makeState();
Expand All @@ -245,7 +245,7 @@ describe('handleSurfaceLeftClick — food pile mark', () => {

it('is a no-op while panInputState.isPanning is true (mid-pan left-click is pan continuation)', () => {
const world = makeWorld({
foodPiles: [{ foodPileId: 7, tileX: 10, tileY: 20 }],
foodPiles: [{ foodPileId: 7, tileX: 10, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50}],
});
const vs = makeViewState('surface', 64, 64);
const state = makeState();
Expand Down Expand Up @@ -289,7 +289,7 @@ describe('handleSurfaceLeftClick — ant-activity popup dismissal must not fall
await import('../render/ant-activity-panel-state.js');
const world = makeWorld({
tick: 5,
foodPiles: [{ foodPileId: 7, tileX: 10, tileY: 20 }],
foodPiles: [{ foodPileId: 7, tileX: 10, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50}],
});
const vs = makeViewState('surface', 64, 64);
const state = makeState();
Expand Down Expand Up @@ -358,7 +358,7 @@ describe('handleSurfaceLeftClick — ant-activity popup dismissal must not fall
await import('../render/ant-activity-panel-state.js');
const world = makeWorld({
tick: 5,
foodPiles: [{ foodPileId: 7, tileX: 10, tileY: 20 }],
foodPiles: [{ foodPileId: 7, tileX: 10, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50}],
});
const vs = makeViewState('surface', 64, 64);
const state = makeState();
Expand Down Expand Up @@ -439,7 +439,7 @@ describe('handleSurfaceLeftClick — ant-activity popup dismissal must not fall
await import('../render/ant-activity-panel-state.js');
const world = makeWorld({
tick: 5,
foodPiles: [{ foodPileId: 7, tileX: 10, tileY: 20 }],
foodPiles: [{ foodPileId: 7, tileX: 10, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50}],
});
const vs = makeViewState('surface', 64, 64);
const state = makeState();
Expand Down Expand Up @@ -509,7 +509,7 @@ describe('handleSurfaceLeftClick — entrance designation confirmation', () => {

it('falls through to food-pile check when tileX does not match pending', () => {
const world = makeWorld({
foodPiles: [{ foodPileId: 99, tileX: 20, tileY: 30 }],
foodPiles: [{ foodPileId: 99, tileX: 20, tileY: 30 , pickupsRemaining: 50, pickupsInitial: 50}],
});
const vs = makeViewState('surface', 64, 64);
const state = makeState(15, 30); // pending entrance at (15, 30)
Expand Down Expand Up @@ -582,7 +582,7 @@ describe('handleSurfaceRightClick', () => {
const world = makeWorld({
surfaceWidth: 128,
surfaceHeight: 128,
foodPiles: [{ foodPileId: 1, tileX: 40, tileY: 50 }],
foodPiles: [{ foodPileId: 1, tileX: 40, tileY: 50 , pickupsRemaining: 50, pickupsInitial: 50}],
});
const vs = makeViewState('surface', 64, 64);
const state = makeState();
Expand Down Expand Up @@ -611,7 +611,7 @@ describe('handleSurfaceRightClick', () => {
const world = makeWorld({
surfaceWidth: 128,
surfaceHeight: 128,
foodPiles: [{ foodPileId: 1, tileX: 40, tileY: 50 }],
foodPiles: [{ foodPileId: 1, tileX: 40, tileY: 50 , pickupsRemaining: 50, pickupsInitial: 50}],
});
const vs = makeViewState('surface', 64, 64);
const state = makeState(10, 20); // prior valid preview
Expand All @@ -636,7 +636,7 @@ describe('isEmptySurfaceTile', () => {
it('returns false when tile has a food pile', () => {
const world = makeWorld({
surfaceWidth: 128, surfaceHeight: 4,
foodPiles: [{ foodPileId: 1, tileX: 10, tileY: 1 }],
foodPiles: [{ foodPileId: 1, tileX: 10, tileY: 1 , pickupsRemaining: 50, pickupsInitial: 50}],
});
expect(isEmptySurfaceTile(world, 10, 1)).toBe(false);
});
Expand Down Expand Up @@ -696,7 +696,7 @@ describe('surface-input rally-point fall-through (SURF-04)', () => {
const world = makeWorld({
tick: 0,
surfaceWidth: 128, surfaceHeight: 4,
foodPiles: [{ foodPileId: 1, tileX: 10, tileY: 1 }],
foodPiles: [{ foodPileId: 1, tileX: 10, tileY: 1 , pickupsRemaining: 50, pickupsInitial: 50}],
});
const vs = makeViewState('surface', 64, 2);
const state = makeState();
Expand Down
8 changes: 4 additions & 4 deletions src/platform/debug-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ describe('buildAntTrace — movement source inference', () => {
it('SearchingFood + food pile within scent radius → "scent"', () => {
const { world, antId } = makeAnt(ForagingSubState.SearchingFood);
// Pile 5 tiles away (well within DEBUG_SCENT_RADIUS = 15).
world.foodPiles.push({ foodPileId: 1, tileX: 25, tileY: 20 });
world.foodPiles.push({ foodPileId: 1, tileX: 25, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50});
expect(buildAntTrace(world, antId).movementSource).toBe('scent');
});

Expand All @@ -329,15 +329,15 @@ describe('buildAntTrace — movement source inference', () => {
const { world, antId } = makeAnt(ForagingSubState.SearchingFood);
world.ants.targetPosX[antId] = 30 << FP_SHIFT;
world.ants.targetPosY[antId] = 30 << FP_SHIFT;
world.foodPiles.push({ foodPileId: 1, tileX: 25, tileY: 20 });
world.foodPiles.push({ foodPileId: 1, tileX: 25, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50});
const key = pheromoneGridKey(COLONY_ID, PheromoneType.FoodTrail, 'surface');
phSet(world.pheromoneGrids[key]!, 21, 20, FOOD_TRAIL_DEPOSIT);
expect(buildAntTrace(world, antId).movementSource).toBe('priority');
});

it('scent overrides pheromone (decision order preserved)', () => {
const { world, antId } = makeAnt(ForagingSubState.SearchingFood);
world.foodPiles.push({ foodPileId: 1, tileX: 25, tileY: 20 });
world.foodPiles.push({ foodPileId: 1, tileX: 25, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50});
const key = pheromoneGridKey(COLONY_ID, PheromoneType.FoodTrail, 'surface');
phSet(world.pheromoneGrids[key]!, 21, 20, FOOD_TRAIL_DEPOSIT);
expect(buildAntTrace(world, antId).movementSource).toBe('scent');
Expand All @@ -349,7 +349,7 @@ describe('buildAntTrace — movement source inference', () => {
const { world, antId } = makeAnt(ForagingSubState.SearchingFood, Zone.Underground);
// Populate the surface grid with a nearby pile + pheromone — if the
// classifier incorrectly ran the surface cascade, one of these would win.
world.foodPiles.push({ foodPileId: 1, tileX: 25, tileY: 20 });
world.foodPiles.push({ foodPileId: 1, tileX: 25, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50});
const key = pheromoneGridKey(COLONY_ID, PheromoneType.FoodTrail, 'surface');
phSet(world.pheromoneGrids[key]!, 21, 20, FOOD_TRAIL_DEPOSIT);
expect(buildAntTrace(world, antId).movementSource).toBe('underground-exit');
Expand Down
138 changes: 132 additions & 6 deletions src/platform/save.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
serializeWorldState, deserializeWorldState,
hasSave, loadSave, deleteSave, tickAutosave,
migrateBehaviorRatio,
parseSaveFile,
FutureSimVersionError,
SaveVersionMismatchError,
type SaveFile,
} from './save.js';
import { createScenario } from '../sim/scenario.js';
Expand Down Expand Up @@ -1464,32 +1466,32 @@ describe('save.ts (SCEN-04 + SCEN-06)', () => {
const s = serializeWorldState(w);
const oversized = [];
for (let i = 0; i < 1000; i++) {
oversized.push({ foodPileId: i, tileX: i % 128, tileY: 0 });
oversized.push({ foodPileId: i, tileX: i % 128, tileY: 0 , pickupsRemaining: 50, pickupsInitial: 50});
}
s.foodPiles = oversized;
expect(() => deserializeWorldState(s)).toThrow();
});
it('#109 rejects foodPile.tileX: out-of-grid', () => {
const w = createScenario(42);
const s = serializeWorldState(w);
s.foodPiles = [{ foodPileId: 1, tileX: 1_000_000, tileY: 0 }];
s.foodPiles = [{ foodPileId: 1, tileX: 1_000_000, tileY: 0 , pickupsRemaining: 50, pickupsInitial: 50}];
expect(() => deserializeWorldState(s)).toThrow();
});
it('#109 rejects foodPiles: duplicate foodPileId', () => {
const w = createScenario(42);
const s = serializeWorldState(w);
s.foodPiles = [
{ foodPileId: 5, tileX: 10, tileY: 10 },
{ foodPileId: 5, tileX: 20, tileY: 20 },
{ foodPileId: 5, tileX: 10, tileY: 10 , pickupsRemaining: 50, pickupsInitial: 50},
{ foodPileId: 5, tileX: 20, tileY: 20 , pickupsRemaining: 50, pickupsInitial: 50},
];
expect(() => deserializeWorldState(s)).toThrow();
});
it('#109 rejects foodPiles: duplicate tile', () => {
const w = createScenario(42);
const s = serializeWorldState(w);
s.foodPiles = [
{ foodPileId: 5, tileX: 10, tileY: 10 },
{ foodPileId: 6, tileX: 10, tileY: 10 },
{ foodPileId: 5, tileX: 10, tileY: 10 , pickupsRemaining: 50, pickupsInitial: 50},
{ foodPileId: 6, tileX: 10, tileY: 10 , pickupsRemaining: 50, pickupsInitial: 50},
];
expect(() => deserializeWorldState(s)).toThrow();
});
Expand Down Expand Up @@ -1581,4 +1583,128 @@ describe('save.ts (SCEN-04 + SCEN-06)', () => {
expect(s2.rngState).toBe(s1.rngState);
});
});

// ---------------------------------------------------------------------------
// Issue #112 — finite food piles + recentlyDepletedFood persistence
// ---------------------------------------------------------------------------
describe('Issue #112 — depletion / spawn save format (v3)', () => {
it('SAVE_FORMAT_VERSION === 3 and SAVE_KEY ends with :v3', () => {
expect(SAVE_FORMAT_VERSION).toBe(3);
expect(SAVE_KEY).toBe('subterrans:save:v3');
});

it('rejects v2 envelopes with SaveVersionMismatchError', () => {
const v2Envelope = JSON.stringify({
version: 2,
seed: 42,
inputLog: [],
snapshot: serializeWorldState(createScenario(42)),
});
// hasSave / loadSave swallow the error; assert their no-save outcome
// AND call parseSaveFile directly to confirm the typed throw — a
// future refactor that downgrades to a generic Error would slip past
// the swallowed-error path otherwise.
localStorage.setItem(SAVE_KEY, v2Envelope);
expect(hasSave()).toBe(false);
expect(loadSave()).toBeNull();

// Direct production-path assertion — calls the module-exported
// parseSaveFile, which is the function that actually decides to throw.
expect(() => parseSaveFile(v2Envelope)).toThrow(SaveVersionMismatchError);
});

it('purges legacy v2 keys on hasSave/loadSave', () => {
localStorage.setItem('subterrans:save:v2', '{"version":2}');
// Trigger the purge.
hasSave();
expect(localStorage.getItem('subterrans:save:v2')).toBeNull();
});

it('round-trips pickup-charge fields on every food pile', () => {
const w = createScenario(42);
const beforeShapes = w.foodPiles.map((p) => ({
foodPileId: p.foodPileId,
pickupsRemaining: p.pickupsRemaining,
pickupsInitial: p.pickupsInitial,
}));
const s = serializeWorldState(w);
const w2 = deserializeWorldState(s);
expect(w2.foodPiles.length).toBe(w.foodPiles.length);
for (let i = 0; i < w2.foodPiles.length; i++) {
const after = w2.foodPiles[i]!;
const before = beforeShapes[i]!;
expect(after.foodPileId).toBe(before.foodPileId);
expect(after.pickupsRemaining).toBe(before.pickupsRemaining);
expect(after.pickupsInitial).toBe(before.pickupsInitial);
}
});

it('round-trips recentlyDepletedFood — entries preserved field-by-field', () => {
const w = createScenario(42);
// Manually populate recentlyDepletedFood with a few synthetic entries
// so the round-trip exercises the new array path.
w.recentlyDepletedFood.push(
{ tick: 100, tileX: 5, tileY: 7 },
{ tick: 200, tileX: 12, tileY: 18 },
);
const s = serializeWorldState(w);
const w2 = deserializeWorldState(s);
expect(w2.recentlyDepletedFood.length).toBe(2);
expect(w2.recentlyDepletedFood[0]).toEqual({ tick: 100, tileX: 5, tileY: 7 });
expect(w2.recentlyDepletedFood[1]).toEqual({ tick: 200, tileX: 12, tileY: 18 });
});

it('validateFoodPile rejects pickupsRemaining=0 (live piles always have a charge)', () => {
const w = createScenario(42);
const s = serializeWorldState(w) as unknown as { foodPiles: { pickupsRemaining: number }[] };
s.foodPiles[0]!.pickupsRemaining = 0; // tampered value
expect(() => deserializeWorldState(s as never)).toThrow(/pickupsRemaining/);
});

it('validateFoodPile rejects pickupsRemaining > pickupsInitial', () => {
const w = createScenario(42);
const s = serializeWorldState(w) as unknown as {
foodPiles: { pickupsRemaining: number; pickupsInitial: number }[];
};
s.foodPiles[0]!.pickupsRemaining = s.foodPiles[0]!.pickupsInitial + 1;
expect(() => deserializeWorldState(s as never)).toThrow(/pickupsRemaining/);
});

it('validateFoodPile rejects pickupsInitial=0', () => {
const w = createScenario(42);
const s = serializeWorldState(w) as unknown as {
foodPiles: { pickupsInitial: number; pickupsRemaining: number }[];
};
s.foodPiles[0]!.pickupsInitial = 0;
s.foodPiles[0]!.pickupsRemaining = 0;
expect(() => deserializeWorldState(s as never)).toThrow(/pickupsInitial/);
});

it('validateFoodPile rejects pickupsInitial above max constant', () => {
const w = createScenario(42);
const s = serializeWorldState(w) as unknown as {
foodPiles: { pickupsInitial: number }[];
};
// FOOD_PILE_INITIAL_PICKUPS_MAX = 150 in constants.ts — pick one above.
s.foodPiles[0]!.pickupsInitial = 1000;
expect(() => deserializeWorldState(s as never)).toThrow(/pickupsInitial/);
});

it('rejects non-array recentlyDepletedFood', () => {
const w = createScenario(42);
const s = serializeWorldState(w) as unknown as { recentlyDepletedFood: unknown };
s.recentlyDepletedFood = 'not an array';
expect(() => deserializeWorldState(s as never)).toThrow(/recentlyDepletedFood/);
});

it('rejects recentlyDepletedFood entries with invalid tile coords', () => {
const w = createScenario(42);
w.recentlyDepletedFood.push({ tick: 1, tileX: 5, tileY: 5 });
const s = serializeWorldState(w) as unknown as {
recentlyDepletedFood: { tick: number; tileX: number; tileY: number }[];
};
s.recentlyDepletedFood[0]!.tileX = -1; // out of bounds
expect(() => deserializeWorldState(s as never)).toThrow(/recentlyDepletedFood\[0\]\.tileX/);
});
});
});
Loading