import assert from 'node:assert/strict'; import test from 'node:test'; import { protectOrderedOpportunityLast, reorderOrderedOpportunity } from '../game/ordered-opportunity'; import { reorderSaphoMovementOrder, saphoMovementOrderEvent, SaphoMovementOrderError, type SaphoMovementOrderContext, } from '../game/sapho-movement-order'; function context(started = false): SaphoMovementOrderContext { return { queue: { event: 'movement:3', eligible: ['done', 'active', 'later', 'holder', 'guild'], remaining: ['active', 'later', 'holder', 'guild'], completed: ['done'], current: 'active', currentStarted: started, protectedLast: null, }, started: started ? ['done', 'active'] : ['done'], guild: { player: 'guild', granted: false, locked: false }, }; } function reorder(input: SaphoMovementOrderContext, player: string, mode: 'first' | 'last') { return reorderSaphoMovementOrder(input, { event: saphoMovementOrderEvent(input), player, mode }); } function reject(input: SaphoMovementOrderContext, player: string, mode: 'first' | 'last', event?: string) { const before = structuredClone(input); assert.throws(() => reorderSaphoMovementOrder(input, { event: event ?? saphoMovementOrderEvent(input), player, mode, }), SaphoMovementOrderError); assert.deepEqual(input, before); } void test('clean first and last retain the existing immutable ordered-opportunity semantics', () => { for (const mode of ['first', 'last'] as const) { const input = context(), before = structuredClone(input); const quote = reorder(input, 'holder', mode); const expected = mode === 'first' ? reorderOrderedOpportunity(input.queue, { event: input.queue.event, player: 'holder', position: 'first' }) : protectOrderedOpportunityLast(input.queue, { event: input.queue.event, player: 'holder' }); assert.equal(quote.event, input.queue.event); assert.equal(quote.afterCurrent, null); assert.deepEqual(quote.next, expected); assert.deepEqual(input, before); assert.notEqual(quote.next.remaining, input.queue.remaining); assert.notEqual(quote.next.eligible, input.queue.eligible); assert.notEqual(quote.next.completed, input.queue.completed); } const ownCleanTurn = reorder(context(), 'active', 'last'); assert.deepEqual(ownCleanTurn.next.remaining, ['later', 'holder', 'guild', 'active']); assert.equal(ownCleanTurn.next.protectedLast, 'active'); }); void test('started-tail first preserves the active combined turn and all other relative positions', () => { const input = context(true), before = structuredClone(input); const quote = reorder(input, 'holder', 'first'); assert.equal(quote.afterCurrent, 'active'); assert.deepEqual(quote.next.remaining, ['active', 'holder', 'later', 'guild']); assert.equal(quote.next.current, 'active'); assert.equal(quote.next.currentStarted, true); assert.equal(quote.next.event, 'movement:3'); assert.notEqual(quote.event, quote.next.event); assert.ok(quote.event.startsWith('sapho-after:')); assert.deepEqual(input, before); }); void test('started-tail last includes Guild while retaining the active turn and base protection event', () => { const input = context(true), before = structuredClone(input); const quote = reorder(input, 'holder', 'last'); assert.deepEqual(quote.next.remaining, ['active', 'later', 'guild', 'holder']); assert.equal(quote.next.current, 'active'); assert.equal(quote.afterCurrent, 'active'); assert.equal(quote.next.protectedLast, 'holder'); assert.equal(quote.next.event, input.queue.event); assert.deepEqual(input, before); assert.throws(() => saphoMovementOrderEvent({ ...input, queue: quote.next }), SaphoMovementOrderError); }); void test('an already started actor cannot use either mode to split its combined turn', () => { reject(context(true), 'active', 'first'); reject(context(true), 'active', 'last'); const anotherStarted = context(true); anotherStarted.started = ['active', 'holder']; reject(anotherStarted, 'later', 'last', 'movement:3'); const inconsistentCurrent = context(); inconsistentCurrent.started = ['active']; reject(inconsistentCurrent, 'holder', 'first', 'movement:3'); const missingCurrent = context(true); missingCurrent.started = ['done']; reject(missingCurrent, 'holder', 'first', 'movement:3'); }); void test('granted Guild priority blocks clean displacement but a started Guild turn stays first during tail reorder', () => { const input = context(); input.queue = { ...input.queue, remaining: ['guild', 'later', 'holder', 'active'], current: 'guild' }; input.guild = { player: 'guild', granted: true, locked: false }; reject(input, 'holder', 'first'); assert.deepEqual(reorder(input, 'holder', 'last').next.remaining, ['guild', 'later', 'active', 'holder']); input.queue = { ...input.queue, currentStarted: true }; input.started = ['done', 'guild']; const before = structuredClone(input), quote = reorder(input, 'holder', 'first'); assert.deepEqual(quote.next.remaining, ['guild', 'holder', 'later', 'active']); assert.equal(quote.next.current, 'guild'); assert.equal(quote.afterCurrent, 'guild'); assert.deepEqual(input, before); }); void test('ordinary Guild timing flags and Basic null timing retain their own later native opportunities', () => { const input = context(), before = structuredClone(input); assert.deepEqual(reorder(input, 'holder', 'first').next.remaining, ['holder', 'active', 'later', 'guild']); assert.deepEqual(input.guild, { player: 'guild', granted: false, locked: false }); assert.deepEqual(input, before); const basic = { ...context(), guild: null }; assert.deepEqual(reorder(basic, 'holder', 'last').next.remaining, ['active', 'later', 'guild', 'holder']); const locked = context(); locked.queue = { ...locked.queue, remaining: ['guild', 'later', 'holder', 'active'], current: 'guild' }; locked.guild = { player: 'guild', granted: true, locked: true }; assert.equal(reorder(locked, 'holder', 'first').next.current, 'holder'); }); void test('started event binds the base phase, active actor and complete public tail with encoded identities', () => { const input = context(true), event = saphoMovementOrderEvent(input); assert.equal(saphoMovementOrderEvent(structuredClone(input)), event); assert.equal(saphoMovementOrderEvent({ ...input, started: ['active'] }), event); reject(input, 'holder', 'first', input.queue.event); const reordered = context(true); reordered.queue = { ...reordered.queue, remaining: ['active', 'later', 'guild', 'holder'] }; reject(reordered, 'holder', 'first', event); const nextPhase = context(true); nextPhase.queue = { ...nextPhase.queue, event: 'movement:4' }; reject(nextPhase, 'holder', 'first', event); const differentCurrent = context(true); differentCurrent.queue = { ...differentCurrent.queue, remaining: ['later', 'active', 'holder', 'guild'], current: 'later' }; differentCurrent.started = ['later']; reject(differentCurrent, 'holder', 'last', event); const encoded = context(true); const rename = (id: string) => id === 'active' ? 'actor:/"[]' : id; encoded.queue = { ...encoded.queue, eligible: encoded.queue.eligible.map(rename), remaining: encoded.queue.remaining.map(rename), current: rename('active') }; encoded.started = [rename('active')]; assert.notEqual(saphoMovementOrderEvent(encoded), event); assert.ok(saphoMovementOrderEvent(encoded).includes('%22')); }); void test('no-op, completed, foreign, protected and malformed requests cannot consume Sapho', () => { reject(context(), 'active', 'first'); reject(context(), 'guild', 'last'); reject(context(true), 'later', 'first'); reject(context(true), 'guild', 'last'); reject(context(), 'done', 'last'); reject(context(), 'foreign', 'first'); const protectedLast = context(); protectedLast.queue = { ...protectedLast.queue, protectedLast: 'guild' }; reject(protectedLast, 'holder', 'first', 'movement:3'); const unknownStarted = context(); unknownStarted.started = ['foreign']; reject(unknownStarted, 'holder', 'first', 'movement:3'); const duplicateStarted = context(true); duplicateStarted.started = ['active', 'active']; reject(duplicateStarted, 'holder', 'last', 'movement:3'); const malformed = context(); malformed.queue = { ...malformed.queue, current: 'holder' }; reject(malformed, 'holder', 'first', 'movement:3'); const finished = context(); finished.queue = { ...finished.queue, remaining: [], completed: finished.queue.eligible, current: null }; reject(finished, 'holder', 'first', 'movement:3'); });