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'); }); });