- presets/templates/: 3 light era templates (ww2/present/near-future) + index.json manifest (id, name, year, eraDescription, suggestedPowers, tensions). - server/templates.ts: createTemplateStore (mirror of createPresetStore). - shared/scenario.ts: dependency-free contract for the dynamic scenario path — SuggestedPower / EraTemplate / TemplateMeta / WorldNation / ScenarioEvent / WorldDraft / Checkpoint types, relationFromScore, validateWorldDraft (pure, defensive, never throws), worldDraftToGameState (region-key territory via the injected expand resolver, regionStats seeded, clean turn-1 state). - tests: server/templates.test.ts (manifest order + file shape), server/scenario-shared.test.ts (15 cases: validation/clamp/dedup/null + transform). Fixes #52
236 lines
9.3 KiB
TypeScript
236 lines
9.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
relationFromScore,
|
|
validateWorldDraft,
|
|
worldDraftToGameState,
|
|
type WorldDraft,
|
|
} from '../shared/scenario';
|
|
import type { NationStats } from '../shared/types';
|
|
|
|
// A deterministic fake ISO_A3 -> region-key expander (two sub-regions per
|
|
// country), standing in for server/regions.expandCountriesToKeys in these
|
|
// pure-function tests.
|
|
const fakeExpand = (countries: string[]): string[] =>
|
|
countries.flatMap((c) => [`${c}-a`, `${c}-b`]);
|
|
|
|
const S = (m: number, e: number, s: number, p: number): NationStats => ({
|
|
military: m,
|
|
economy: e,
|
|
stability: s,
|
|
population: p,
|
|
});
|
|
|
|
describe('relationFromScore (v0.31.0, #52)', () => {
|
|
it('maps the five sentiment bands to RelationStatus', () => {
|
|
expect(relationFromScore(100)).toBe('ally');
|
|
expect(relationFromScore(60)).toBe('ally');
|
|
expect(relationFromScore(0)).toBe('neutral');
|
|
expect(relationFromScore(30)).toBe('neutral');
|
|
expect(relationFromScore(-1)).toBe('rival');
|
|
expect(relationFromScore(-40)).toBe('rival');
|
|
expect(relationFromScore(-41)).toBe('hostile');
|
|
expect(relationFromScore(-80)).toBe('hostile');
|
|
expect(relationFromScore(-81)).toBe('war');
|
|
expect(relationFromScore(-100)).toBe('war');
|
|
});
|
|
|
|
it('clamps out-of-range scores before banding', () => {
|
|
expect(relationFromScore(250)).toBe('ally');
|
|
expect(relationFromScore(-250)).toBe('war');
|
|
});
|
|
});
|
|
|
|
describe('validateWorldDraft (v0.31.0, #52)', () => {
|
|
it('returns a clean draft from a valid payload', () => {
|
|
const raw = {
|
|
nations: [
|
|
{ id: 'de', name: 'German Reich', type: 'major', stats: S(95, 70, 75, 90), territory: ['DEU'], relations: { uk: 80 } },
|
|
{ id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: 80 } },
|
|
],
|
|
};
|
|
const d = validateWorldDraft(raw);
|
|
expect(d).not.toBeNull();
|
|
const draft = d as WorldDraft;
|
|
expect(draft.nations).toHaveLength(2);
|
|
expect(draft.nations[0]).toMatchObject({ id: 'de', name: 'German Reich', type: 'major' });
|
|
expect(draft.nations[0].stats).toEqual(S(95, 70, 75, 90));
|
|
expect(draft.nations[0].territory).toEqual(['DEU']);
|
|
expect(draft.nations[0].relations).toEqual({ uk: 80 });
|
|
expect(draft.events).toBeUndefined();
|
|
});
|
|
|
|
it('clamps out-of-range stat values to 0-100 and defaults missing to 50', () => {
|
|
const raw = {
|
|
nations: [
|
|
{ name: 'Over', stats: { military: 150, economy: -20, stability: 7 }, territory: ['DEU'] },
|
|
],
|
|
};
|
|
const d = validateWorldDraft(raw) as WorldDraft;
|
|
expect(d.nations[0].stats).toEqual(S(100, 0, 7, 50)); // population missing -> 50
|
|
});
|
|
|
|
it('upper-cases, de-dupes, and (given valid codes) filters territory to known ISO_A3', () => {
|
|
const codes = new Set(['DEU', 'GBR', 'FRA']);
|
|
const raw = {
|
|
nations: [
|
|
{
|
|
name: 'Mixed',
|
|
// lower-case, duplicated, and one unknown code (USA) to be dropped.
|
|
territory: ['deu', 'DEU', 'usa', 'gbr'],
|
|
},
|
|
],
|
|
};
|
|
const d = validateWorldDraft(raw, { validCountryCodes: codes }) as WorldDraft;
|
|
expect(d.nations[0].territory).toEqual(['DEU', 'GBR']);
|
|
});
|
|
|
|
it('keeps unknown codes when no validCountryCodes set is supplied', () => {
|
|
const raw = { nations: [{ name: 'X', territory: ['AAA', 'BBB'] }] };
|
|
const d = validateWorldDraft(raw) as WorldDraft;
|
|
expect(d.nations[0].territory).toEqual(['AAA', 'BBB']);
|
|
});
|
|
|
|
it('resolves relation keys by id or (case-insensitive) name and drops self/unknown refs', () => {
|
|
const raw = {
|
|
nations: [
|
|
{ id: 'de', name: 'Germany', territory: ['DEU'], relations: { 'United Kingdom': 55, de: 99, ghost: 10 } },
|
|
{ id: 'uk', name: 'United Kingdom', territory: ['GBR'], relations: { de: -70 } },
|
|
],
|
|
};
|
|
const d = validateWorldDraft(raw) as WorldDraft;
|
|
// de: the name-keyed ref resolves to uk, self (de) and unknown (ghost) dropped.
|
|
expect(d.nations[0].relations).toEqual({ uk: 55 });
|
|
// uk: the id-keyed ref to de is kept, clamped (already in range).
|
|
expect(d.nations[1].relations).toEqual({ de: -70 });
|
|
});
|
|
|
|
it('clamps relation scores to -100..100', () => {
|
|
const raw = {
|
|
nations: [
|
|
{ id: 'a', name: 'A', territory: ['AAA'], relations: { b: 500 } },
|
|
{ id: 'b', name: 'B', territory: ['BBB'], relations: { a: -500 } },
|
|
],
|
|
};
|
|
const d = validateWorldDraft(raw) as WorldDraft;
|
|
expect(d.nations[0].relations).toEqual({ b: 100 });
|
|
expect(d.nations[1].relations).toEqual({ a: -100 });
|
|
});
|
|
|
|
it('suffices duplicate ids until unique and derives ids from names', () => {
|
|
const raw = {
|
|
nations: [
|
|
{ id: 'x', name: 'First', territory: ['AAA'] },
|
|
{ id: 'x', name: 'Second', territory: ['BBB'] },
|
|
{ name: 'United Kingdom', territory: ['GBR'] }, // no id -> slug
|
|
],
|
|
};
|
|
const d = validateWorldDraft(raw) as WorldDraft;
|
|
const ids = d.nations.map((n) => n.id);
|
|
expect(ids).toEqual(['x', 'x-2', 'united-kingdom']);
|
|
});
|
|
|
|
it('keeps backstory events that name a known nation and drops the rest', () => {
|
|
const raw = {
|
|
nations: [
|
|
{ id: 'de', name: 'Germany', territory: ['DEU'] },
|
|
{ id: 'uk', name: 'United Kingdom', territory: ['GBR'] },
|
|
],
|
|
events: [
|
|
{ nation: 'de', type: 'military', title: 'Remilitarization', text: 't', outcome: 'success' },
|
|
{ nation: 'United Kingdom', type: 'political', title: 'By-elections', text: 't', outcome: 'neutral' },
|
|
{ nation: 'ghost', type: 'political', title: 'No owner', text: 't', outcome: 'neutral' },
|
|
{ nation: 'de', type: 'political' }, // no title -> dropped
|
|
],
|
|
};
|
|
const d = validateWorldDraft(raw) as WorldDraft;
|
|
expect(d.events).toHaveLength(2);
|
|
expect(d.events?.[0]).toMatchObject({ nation: 'de', type: 'military', title: 'Remilitarization', outcome: 'success' });
|
|
expect(d.events?.[1]).toMatchObject({ nation: 'uk', type: 'political', title: 'By-elections', outcome: 'neutral' });
|
|
});
|
|
|
|
it('returns null (never throws) for unusable / garbage payloads', () => {
|
|
expect(validateWorldDraft(null)).toBeNull();
|
|
expect(validateWorldDraft('nope')).toBeNull();
|
|
expect(validateWorldDraft([])).toBeNull();
|
|
expect(validateWorldDraft({})).toBeNull();
|
|
expect(validateWorldDraft({ nations: [] })).toBeNull();
|
|
expect(validateWorldDraft({ nations: [{ name: 'No territory' }] })).toBeNull();
|
|
expect(validateWorldDraft({ nations: [{}] })).toBeNull(); // no name
|
|
expect(validateWorldDraft({ nations: [42] })).toBeNull(); // not an object
|
|
// no nation owns a *valid* territory -> unusable.
|
|
expect(
|
|
validateWorldDraft(
|
|
{ nations: [{ name: 'X', territory: ['ZZZ'] }] },
|
|
{ validCountryCodes: new Set(['DEU']) },
|
|
),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('worldDraftToGameState (v0.31.0, #52)', () => {
|
|
function draft(): WorldDraft {
|
|
return {
|
|
nations: [
|
|
{ id: 'de', name: 'Germany', type: 'major', stats: S(90, 70, 75, 90), territory: ['DEU'], relations: { uk: 70 } },
|
|
{ id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: -90 } },
|
|
],
|
|
events: [{ nation: 'de', type: 'political', title: 'The new order', text: 'A fresh map is drawn.', outcome: 'neutral' }],
|
|
};
|
|
}
|
|
|
|
it('expands ISO_A3 territory to region keys and seeds per-region stats', () => {
|
|
const g = worldDraftToGameState(draft(), fakeExpand, 'uk', {
|
|
id: 'g1',
|
|
title: 'Custom World',
|
|
startDate: '1939-09-01',
|
|
});
|
|
expect(g.id).toBe('g1');
|
|
expect(g.presetTitle).toBe('Custom World');
|
|
expect(g.date).toBe('1939-09-01');
|
|
expect(g.turn).toBe(1);
|
|
expect(g.version).toBe(1);
|
|
expect(g.playerNationId).toBe('uk');
|
|
expect(g.unowned).toEqual([]);
|
|
expect(g.markers).toEqual([]);
|
|
expect(g.pendingNationOps).toEqual([]);
|
|
expect(g.pending).toEqual([]);
|
|
|
|
// de owns DEU -> two region keys; uk owns GBR+IRL -> four.
|
|
expect(g.nations.de.territory).toEqual(['DEU-a', 'DEU-b']);
|
|
expect(g.nations.uk.territory).toEqual(['GBR-a', 'GBR-b', 'IRL-a', 'IRL-b']);
|
|
|
|
// Every owned region is seeded with a copy of its owner's stats.
|
|
expect(g.regionStats['DEU-a']).toEqual(g.nations.de.stats);
|
|
expect(g.regionStats['IRL-b']).toEqual(g.nations.uk.stats);
|
|
// No entry for unowned regions.
|
|
expect(g.regionStats['ZZZ-a']).toBeUndefined();
|
|
});
|
|
|
|
it('maps numeric relations to RelationStatus', () => {
|
|
const g = worldDraftToGameState(draft(), fakeExpand, 'de');
|
|
expect(g.nations.de.relations).toEqual({ uk: 'ally' }); // 70 -> ally
|
|
expect(g.nations.uk.relations).toEqual({ de: 'war' }); // -90 -> war
|
|
});
|
|
|
|
it('seeds the event feed from backstory events at turn 1 / startDate', () => {
|
|
const g = worldDraftToGameState(draft(), fakeExpand, 'de', { startDate: '2035-01-01' });
|
|
expect(g.events).toHaveLength(1);
|
|
expect(g.events[0]).toMatchObject({
|
|
turn: 1,
|
|
date: '2035-01-01',
|
|
nation: 'de',
|
|
type: 'political',
|
|
title: 'The new order',
|
|
outcome: 'neutral',
|
|
});
|
|
});
|
|
|
|
it('uses the defaults (empty id, Custom World, 1970-01-01) when options are omitted', () => {
|
|
const g = worldDraftToGameState(draft(), fakeExpand, 'de');
|
|
expect(g.id).toBe('');
|
|
expect(g.presetTitle).toBe('Custom World');
|
|
expect(g.date).toBe('1970-01-01');
|
|
expect(g.presetId).toBe('dynamic');
|
|
});
|
|
});
|