diff --git a/__tests__/fixtures/factories.ts b/__tests__/fixtures/factories.ts index b357c81..6e42f4c 100644 --- a/__tests__/fixtures/factories.ts +++ b/__tests__/fixtures/factories.ts @@ -517,3 +517,17 @@ export function createOrderWithIO( started, }); } + +export function createTestAlert(overrides: Partial = {}): PrunApi.Alert { + return { + id: nextId('alert'), + type: 'PRODUCTION_ORDER_FINISHED', + contextId: nextId('ctx'), + naturalId: 'XK-745b', + time: createDateTime(), + data: [], + seen: false, + read: false, + ...overrides, + }; +} diff --git a/components/status/AlertsPanel.tsx b/components/status/AlertsPanel.tsx new file mode 100644 index 0000000..23d4834 --- /dev/null +++ b/components/status/AlertsPanel.tsx @@ -0,0 +1,78 @@ +import { useMemo, useState, type ReactNode } from 'react'; +import { Panel } from '../shared'; +import { useAlertsStore } from '../../stores/entities'; +import { formatAlert } from '../../lib/format-alert'; +import { formatRelativeTime } from '../../lib/format-time'; + +// The NOTS list can run long; cap the panel and say so rather than scroll +// forever — the full history lives in APEX. +const MAX_ROWS = 50; + +/** + * APEX notifications surfaced into APXM (the NOTS passthrough, #30): the + * ALERTS_* WebSocket data rendered as a collapsible Status panel, so alerts + * are visible without switching to APEX. Read state is server-driven and + * display-only — marking read would require sending a message, which APXM + * never does. + */ +export function AlertsPanel({ handle }: { handle?: ReactNode }) { + const [collapsed, setCollapsed] = useState(true); + const fetched = useAlertsStore((s) => s.fetched); + const entities = useAlertsStore((s) => s.entities); + + const alerts = useMemo( + () => + Array.from(entities.values()).sort((a, b) => b.time.timestamp - a.time.timestamp), + [entities] + ); + const unread = useMemo(() => alerts.filter((a) => !a.read).length, [alerts]); + const rows = alerts.slice(0, MAX_ROWS); + + return ( + setCollapsed((c) => !c)} + summary={unread > 0 ? `${unread} unread` : `${alerts.length}`} + handle={handle} + > + {!fetched ? ( +

Waiting for game data...

+ ) : alerts.length === 0 ? ( +

No notifications

+ ) : ( +
+ {rows.map((alert) => { + const { text, category } = formatAlert(alert); + return ( +
+ + {category} + + + {/* Unread also gets a marker — colour alone can't carry state */} + {!alert.read && } + {text} + + + {formatRelativeTime(alert.time.timestamp)} + +
+ ); + })} + {alerts.length > MAX_ROWS && ( +

+ +{alerts.length - MAX_ROWS} older — see NOTS in APEX +

+ )} +
+ )} +
+ ); +} diff --git a/components/status/__tests__/reorder-utils.test.ts b/components/status/__tests__/reorder-utils.test.ts index 8e78ef6..8576d0f 100644 --- a/components/status/__tests__/reorder-utils.test.ts +++ b/components/status/__tests__/reorder-utils.test.ts @@ -3,11 +3,11 @@ import { reconcileOrder, applyReorder } from '../reorder-utils'; describe('reconcileOrder', () => { it('returns the canonical order for an empty/default input', () => { - expect(reconcileOrder([])).toEqual(['bases', 'fleet', 'contracts', 'empire']); + expect(reconcileOrder([])).toEqual(['bases', 'fleet', 'contracts', 'empire', 'alerts']); }); it('preserves a valid saved order', () => { - const saved = ['fleet', 'empire', 'contracts', 'bases']; + const saved = ['alerts', 'fleet', 'empire', 'contracts', 'bases']; expect(reconcileOrder(saved)).toEqual(saved); }); @@ -18,16 +18,19 @@ describe('reconcileOrder', () => { // appended in canonical order 'contracts', 'empire', + 'alerts', ]); }); - it('appends known panels missing from a stale saved order', () => { - // Saved order predates 'empire' being added. - expect(reconcileOrder(['fleet', 'bases', 'contracts'])).toEqual([ + it('appends known panels missing from a stale saved order (no migration needed)', () => { + // A persisted order from before 'alerts' existed — the exact upgrade path + // every existing user hits when the NOTS panel ships. + expect(reconcileOrder(['fleet', 'bases', 'contracts', 'empire'])).toEqual([ 'fleet', 'bases', 'contracts', 'empire', + 'alerts', ]); }); @@ -37,6 +40,7 @@ describe('reconcileOrder', () => { 'bases', 'contracts', 'empire', + 'alerts', ]); }); }); diff --git a/components/status/index.ts b/components/status/index.ts index ee441bc..3ea7555 100644 --- a/components/status/index.ts +++ b/components/status/index.ts @@ -4,5 +4,6 @@ export { ContractsMiniList } from './ContractsMiniList'; export { CashBalancePane } from './CashBalancePane'; export { AttentionPanel } from './AttentionPanel'; export { EmpireBurnPanel } from './EmpireBurnPanel'; +export { AlertsPanel } from './AlertsPanel'; export { DragHandle } from './DragHandle'; export { useStatusReorder } from './useStatusReorder'; diff --git a/components/views/StatusView.tsx b/components/views/StatusView.tsx index 13fd90e..4178185 100644 --- a/components/views/StatusView.tsx +++ b/components/views/StatusView.tsx @@ -6,6 +6,7 @@ import { CashBalancePane, AttentionPanel, EmpireBurnPanel, + AlertsPanel, DragHandle, useStatusReorder, } from '../status'; @@ -18,6 +19,7 @@ const PANELS: Record ReactNode> = { fleet: (handle) => , contracts: (handle) => , empire: (handle) => , + alerts: (handle) => , }; export function StatusView() { diff --git a/lib/__tests__/format-alert.test.ts b/lib/__tests__/format-alert.test.ts new file mode 100644 index 0000000..bd08f0f --- /dev/null +++ b/lib/__tests__/format-alert.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { formatAlert } from '../format-alert'; +import type { PrunApi } from '../../types/prun-api'; +import { createTestAlert, createAddress } from '../../__tests__/fixtures/factories'; + +function withData( + type: PrunApi.AlertType, + data: PrunApi.AlertData[] = [] +): PrunApi.Alert { + return createTestAlert({ type, data }); +} + +describe('formatAlert — explicit templates', () => { + it('COMEX_ORDER_FILLED includes quantity and commodity when present', () => { + const result = formatAlert( + withData('COMEX_ORDER_FILLED', [ + { key: 'commodity', value: 'RAT' }, + { key: 'quantity', value: 500 }, + ]) + ); + expect(result.text).toBe('CX order filled: 500x RAT'); + expect(result.category).toBe('trade'); + }); + + it('COMEX_ORDER_FILLED degrades gracefully without data', () => { + expect(formatAlert(withData('COMEX_ORDER_FILLED')).text).toBe('CX order filled'); + }); + + it('PRODUCTION_ORDER_FINISHED composes material and planet', () => { + const result = formatAlert( + withData('PRODUCTION_ORDER_FINISHED', [ + { key: 'material', value: 'PE' }, + { key: 'quantity', value: 200 }, + { key: 'planet', value: { address: createAddress({ planetName: 'Montem' }) } }, + ]) + ); + expect(result.text).toBe('Production finished: 200x PE @ Montem'); + expect(result.category).toBe('production'); + }); + + it('SHIP_FLIGHT_ENDED names the ship and destination', () => { + const result = formatAlert( + withData('SHIP_FLIGHT_ENDED', [ + { key: 'registration', value: 'AVI-04X21' }, + { key: 'destination', value: { address: createAddress({ planetName: 'Promitor' }) } }, + ]) + ); + expect(result.text).toBe('AVI-04X21 arrived at Promitor'); + expect(result.category).toBe('fleet'); + }); + + it('workforce alerts carry the planet and escalate wording', () => { + const planet = { key: 'planet' as const, value: { address: createAddress({ planetName: 'Vallis' }) } }; + expect(formatAlert(withData('WORKFORCE_LOW_SUPPLIES', [planet])).text).toBe( + 'Workforce low on supplies @ Vallis' + ); + expect(formatAlert(withData('WORKFORCE_OUT_OF_SUPPLIES', [planet])).text).toBe( + 'Workforce OUT of supplies @ Vallis' + ); + expect(formatAlert(withData('WORKFORCE_UNSATISFIED', [planet])).category).toBe('workforce'); + }); + + it('contract family shares the verb-phrase template with the partner name', () => { + const result = formatAlert( + withData('CONTRACT_CONTRACT_RECEIVED', [ + { key: 'partner', value: { name: 'Trade Partner Corp' } }, + ]) + ); + expect(result.text).toBe('Contract received — Trade Partner Corp'); + expect(result.category).toBe('contract'); + }); + + it('contract deadline variants both read as deadline exceeded', () => { + expect(formatAlert(withData('CONTRACT_DEADLINE_EXCEEDED_WITH_CONTROL')).text).toBe( + 'Contract deadline exceeded' + ); + expect(formatAlert(withData('CONTRACT_DEADLINE_EXCEEDED_WITHOUT_CONTROL')).text).toBe( + 'Contract deadline exceeded' + ); + }); +}); + +describe('formatAlert — fallback for untemplated types', () => { + it('humanizes the enum name', () => { + const result = formatAlert(withData('GATEWAY_LINK_ESTABLISHED')); + expect(result.text).toBe('Gateway link established'); + expect(result.category).toBe('fleet'); + }); + + it('appends a salient data value when one is recognisable', () => { + const result = formatAlert( + withData('COGC_PROGRAM_CHANGED', [{ key: 'program', value: 'ADVERTISING_AGRICULTURE' }]) + ); + expect(result.text).toBe('Cogc program changed: ADVERTISING_AGRICULTURE'); + expect(result.category).toBe('base'); + }); + + it('never throws on unknown data shapes (boundary rule: hostile input)', () => { + const result = formatAlert( + withData('RELEASE_NOTES', [ + { key: 'weird', value: { nested: [1, 2, 3] } }, + { key: 'quantity', value: 'not-a-number' as unknown as number }, + ]) + ); + expect(result.text).toBe('Release notes'); + expect(result.category).toBe('system'); + }); +}); diff --git a/lib/format-alert.ts b/lib/format-alert.ts new file mode 100644 index 0000000..223d298 --- /dev/null +++ b/lib/format-alert.ts @@ -0,0 +1,176 @@ +import type { PrunApi } from '../types/prun-api'; +import { getEntityDisplayName } from './address'; + +/** + * Alerts arrive with NO display text — just a type enum and {key, value} + * data pairs; the game composes the string client-side in the NOTS buffer. + * rPrun reads the game-rendered DOM instead, but APXM never shows APEX, so + * we compose our own text here. High-frequency types get explicit templates; + * everything else falls back to a humanized type name plus whatever salient + * data values are recognisable. + */ + +export type AlertCategory = + | 'trade' + | 'production' + | 'fleet' + | 'workforce' + | 'contract' + | 'base' + | 'system'; + +export interface FormattedAlert { + text: string; + category: AlertCategory; +} + +function value(alert: PrunApi.Alert, key: string): unknown { + return alert.data.find((d) => d.key === key)?.value; +} + +function str(alert: PrunApi.Alert, key: string): string | undefined { + const v = value(alert, key); + return typeof v === 'string' ? v : undefined; +} + +function num(alert: PrunApi.Alert, key: string): number | undefined { + const v = value(alert, key); + return typeof v === 'number' ? v : undefined; +} + +/** Keys like planet/address/destination wrap an Address: { address: {...} }. */ +function place(alert: PrunApi.Alert, key: string): string | undefined { + const v = value(alert, key) as { address?: PrunApi.Address } | undefined; + return v?.address ? getEntityDisplayName(v.address) : undefined; +} + +function partner(alert: PrunApi.Alert): string | undefined { + const v = value(alert, 'partner') as PrunApi.ContractPartner | undefined; + return v?.name; +} + +/** 'GATEWAY_LINK_ESTABLISHED' → 'Gateway link established' */ +function humanize(type: string): string { + const words = type.toLowerCase().split('_'); + return words[0].charAt(0).toUpperCase() + words[0].slice(1) + ' ' + words.slice(1).join(' '); +} + +function categoryOf(type: PrunApi.AlertType): AlertCategory { + if (type.startsWith('COMEX_') || type.startsWith('FOREX_') || type.startsWith('LOCAL_MARKET_')) + return 'trade'; + if (type.startsWith('PRODUCTION_') || type.startsWith('SHIPYARD_')) return 'production'; + if (type.startsWith('SHIP_') || type.startsWith('GATEWAY_')) return 'fleet'; + if (type.startsWith('WORKFORCE_') || type.startsWith('POPULATION_')) return 'workforce'; + if (type.startsWith('CONTRACT_')) return 'contract'; + if ( + type.startsWith('COGC_') || + type.startsWith('ADMIN_CENTER_') || + type.startsWith('INFRASTRUCTURE_') || + type.startsWith('PLANETARY_') || + type.startsWith('WAREHOUSE_') || + type.startsWith('SITE_') + ) + return 'base'; + return 'system'; +} + +/** "quantity x commodity" when both present, else whichever exists. */ +function amountOf(alert: PrunApi.Alert, materialKey: 'commodity' | 'material'): string | undefined { + const mat = str(alert, materialKey); + const qty = num(alert, 'quantity'); + if (mat && qty !== undefined) return `${qty}x ${mat}`; + return mat; +} + +/** Appends " @ place" / " from partner" style suffixes only when known. */ +function suffix(part: string | undefined, sep: string): string { + return part ? `${sep}${part}` : ''; +} + +// Verb phrases for the CONTRACT_CONTRACT_* family; the shared shape is +// "Contract ". +const CONTRACT_PHRASES: Record = { + CONTRACT_CONDITION_FULFILLED: 'condition fulfilled', + CONTRACT_CONTRACT_BREACHED: 'breached', + CONTRACT_CONTRACT_CANCELLED: 'cancelled', + CONTRACT_CONTRACT_CLOSED: 'closed', + CONTRACT_CONTRACT_EXTENDED: 'extended', + CONTRACT_CONTRACT_RECEIVED: 'received', + CONTRACT_CONTRACT_REJECTED: 'rejected', + CONTRACT_CONTRACT_TERMINATED: 'terminated', + CONTRACT_CONTRACT_TERMINATION_REQUESTED: 'termination requested', + CONTRACT_DEADLINE_EXCEEDED_WITH_CONTROL: 'deadline exceeded', + CONTRACT_DEADLINE_EXCEEDED_WITHOUT_CONTROL: 'deadline exceeded', +}; + +export function formatAlert(alert: PrunApi.Alert): FormattedAlert { + const category = categoryOf(alert.type); + + switch (alert.type) { + case 'COMEX_ORDER_FILLED': + return { category, text: `CX order filled${suffix(amountOf(alert, 'commodity'), ': ')}` }; + case 'COMEX_TRADE': + return { category, text: `CX trade${suffix(amountOf(alert, 'commodity'), ': ')}` }; + case 'FOREX_ORDER_FILLED': + return { category, text: 'FX order filled' }; + case 'FOREX_TRADE': + return { category, text: 'FX trade' }; + case 'PRODUCTION_ORDER_FINISHED': + return { + category, + text: + `Production finished` + + suffix(amountOf(alert, 'material'), ': ') + + suffix(place(alert, 'planet') ?? place(alert, 'address'), ' @ '), + }; + case 'SHIP_FLIGHT_ENDED': + return { + category, + text: + (str(alert, 'registration') ?? 'Ship') + + ' arrived' + + suffix(place(alert, 'destination'), ' at '), + }; + case 'WORKFORCE_LOW_SUPPLIES': + return { + category, + text: `Workforce low on supplies${suffix(place(alert, 'planet') ?? place(alert, 'address'), ' @ ')}`, + }; + case 'WORKFORCE_OUT_OF_SUPPLIES': + return { + category, + text: `Workforce OUT of supplies${suffix(place(alert, 'planet') ?? place(alert, 'address'), ' @ ')}`, + }; + case 'WORKFORCE_UNSATISFIED': + return { + category, + text: `Workforce unsatisfied${suffix(place(alert, 'planet') ?? place(alert, 'address'), ' @ ')}`, + }; + case 'LOCAL_MARKET_AD_ACCEPTED': + return { category, text: `Local market ad accepted${suffix(place(alert, 'address'), ' @ ')}` }; + case 'LOCAL_MARKET_AD_EXPIRED': + return { category, text: `Local market ad expired${suffix(place(alert, 'address'), ' @ ')}` }; + case 'WAREHOUSE_STORE_LOCKED_INSUFFICIENT_FUNDS': + return { category, text: `Warehouse locked — insufficient funds${suffix(place(alert, 'address'), ' @ ')}` }; + case 'WAREHOUSE_STORE_UNLOCKED': + return { category, text: `Warehouse unlocked${suffix(place(alert, 'address'), ' @ ')}` }; + } + + const contractPhrase = CONTRACT_PHRASES[alert.type]; + if (contractPhrase) { + return { + category, + text: `Contract ${contractPhrase}${suffix(partner(alert), ' — ')}`, + }; + } + + // Fallback: humanized type name + the most salient recognisable data value. + const detail = + amountOf(alert, 'material') ?? + amountOf(alert, 'commodity') ?? + str(alert, 'registration') ?? + place(alert, 'planet') ?? + place(alert, 'address') ?? + str(alert, 'program'); + return { category, text: humanize(alert.type) + suffix(detail, ': ') }; +} diff --git a/stores/__tests__/message-handlers.test.ts b/stores/__tests__/message-handlers.test.ts index 199bb81..96e7f51 100644 --- a/stores/__tests__/message-handlers.test.ts +++ b/stores/__tests__/message-handlers.test.ts @@ -10,6 +10,7 @@ import { useShipsStore, useFlightsStore, useContractsStore, + useAlertsStore, useProductionLoadedStore, clearAllEntityStores, } from '../entities'; @@ -21,6 +22,7 @@ import { createTestShip, createTestFlight, createTestContract, + createTestAlert, createProductionOrder, } from '../../__tests__/fixtures/factories'; import { useSiteSourceStore } from '../site-data-sources'; @@ -157,12 +159,17 @@ describe('message-handlers', () => { expect(useStorageStore.getState().entities.size).toBe(1); expect(useShipsStore.getState().entities.size).toBe(1); + useAlertsStore.getState().setAll([createTestAlert()]); + expect(useAlertsStore.getState().entities.size).toBe(1); + // Reconnection (reconnectCount=1) — should clear dispatchMessage('CLIENT_CONNECTION_OPENED', {}); expect(useSitesStore.getState().entities.size).toBe(0); expect(useStorageStore.getState().entities.size).toBe(0); expect(useShipsStore.getState().entities.size).toBe(0); + // Stale notifications after a WS gap must not linger either. + expect(useAlertsStore.getState().entities.size).toBe(0); // Stale ACT data after a WS gap is exactly the staleness hazard — // order books especially must not survive a reconnect. expect(useWarehouseStore.getState().warehouses).toHaveLength(0); @@ -520,6 +527,47 @@ describe('message-handlers', () => { }); }); + describe('ALERTS_* (the NOTS data)', () => { + it('ALERTS_ALERTS populates the store and marks it fetched', () => { + dispatchMessage('ALERTS_ALERTS', { + alerts: [createTestAlert({ id: 'a-1' }), createTestAlert({ id: 'a-2' })], + }); + + expect(useAlertsStore.getState().entities.size).toBe(2); + expect(useAlertsStore.getState().fetched).toBe(true); + expect(useAlertsStore.getState().dataSource).toBe('websocket'); + }); + + it('ALERTS_ALERT upserts a single alert (delta)', () => { + dispatchMessage('ALERTS_ALERT', createTestAlert({ id: 'a-1', read: false })); + dispatchMessage('ALERTS_ALERT', createTestAlert({ id: 'a-1', read: true })); + + expect(useAlertsStore.getState().entities.size).toBe(1); + expect(useAlertsStore.getState().getById('a-1')?.read).toBe(true); + }); + + it('ALERTS_ALERTS_DELETED removes only the listed ids', () => { + dispatchMessage('ALERTS_ALERTS', { + alerts: [createTestAlert({ id: 'a-1' }), createTestAlert({ id: 'a-2' })], + }); + + dispatchMessage('ALERTS_ALERTS_DELETED', { alertIds: ['a-1'] }); + + expect(useAlertsStore.getState().getById('a-1')).toBeUndefined(); + expect(useAlertsStore.getState().getById('a-2')).toBeDefined(); + }); + + it('discards malformed alert deltas without touching the store', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + dispatchMessage('ALERTS_ALERT', { nonsense: true }); + + expect(useAlertsStore.getState().entities.size).toBe(0); + expect(useConnectionStore.getState().discardedMessages).toBe(1); + warnSpy.mockRestore(); + }); + }); + describe('COMPANY_DATA', () => { it('populates the company store', () => { dispatchMessage('COMPANY_DATA', { name: 'Test Co', code: 'TST', countryId: 'NC' }); diff --git a/stores/entities/alerts.ts b/stores/entities/alerts.ts new file mode 100644 index 0000000..8fcb554 --- /dev/null +++ b/stores/entities/alerts.ts @@ -0,0 +1,8 @@ +import { createEntityStore, type EntityStore } from '../create-entity-store'; +import type { PrunApi } from '../../types/prun-api'; + +export type AlertsStore = EntityStore; + +// Not persisted: the full alert list re-arrives with every login dump +// (ALERTS_ALERTS), and stale notifications are worse than none. +export const useAlertsStore = createEntityStore('alerts', (a) => a.id); diff --git a/stores/entities/index.ts b/stores/entities/index.ts index a0391a1..c5403f2 100644 --- a/stores/entities/index.ts +++ b/stores/entities/index.ts @@ -8,6 +8,7 @@ import { useShipsStore } from './ships'; import { useFlightsStore } from './flights'; import { useContractsStore } from './contracts'; import { useBalancesStore } from './balances'; +import { useAlertsStore } from './alerts'; export { useSitesStore, type SitesStore } from './sites'; @@ -43,6 +44,8 @@ export { useContractsStore, type ContractsStore } from './contracts'; export { useBalancesStore, type BalancesStore } from './balances'; +export { useAlertsStore, type AlertsStore } from './alerts'; + // Utility to clear all entity stores (used on reconnect) export function clearAllEntityStores(): void { useSitesStore.getState().clear(); @@ -53,6 +56,7 @@ export function clearAllEntityStores(): void { useFlightsStore.getState().clear(); useContractsStore.getState().clear(); useBalancesStore.getState().clear(); + useAlertsStore.getState().clear(); } // Batch mode — suppresses Zustand listener notifications during bulk @@ -61,7 +65,7 @@ export function clearAllEntityStores(): void { const allStores = [ useSitesStore, useStorageStore, useWorkforceStore, useProductionStore, useShipsStore, useFlightsStore, useContractsStore, - useBalancesStore, + useBalancesStore, useAlertsStore, ]; export function beginEntityBatch(): void { diff --git a/stores/message-handlers.ts b/stores/message-handlers.ts index cf5347e..398e0b3 100644 --- a/stores/message-handlers.ts +++ b/stores/message-handlers.ts @@ -11,6 +11,7 @@ import { useFlightsStore, useContractsStore, useBalancesStore, + useAlertsStore, useProductionLoadedStore, clearAllEntityStores, type WorkforceEntity, @@ -446,6 +447,43 @@ export function initMessageHandlers(): void { } }); + // ============================================================================ + // Alerts (the NOTS buffer's data) — bulk snapshot on login, then singleton + // deltas as things happen, plus explicit deletion notices. + // ============================================================================ + + typeHandlers.set('ALERTS_ALERTS', (msg: ProcessedMessage) => { + const payload = extractPayload(msg) as { alerts?: PrunApi.Alert[] }; + if (Array.isArray(payload?.alerts)) { + useAlertsStore.getState().setAll(payload.alerts); + useAlertsStore.getState().setFetched('websocket'); + } else { + warn('ALERTS_ALERTS: unexpected payload structure', payload); + } + }); + + typeHandlers.set('ALERTS_ALERT', (msg: ProcessedMessage) => { + const alert = extractPayload(msg) as PrunApi.Alert; + if (alert?.id) { + useAlertsStore.getState().setOne(alert); + } else { + warn('ALERTS_ALERT: unexpected payload structure', alert); + useConnectionStore.getState().incrementDiscarded(); + } + }); + + typeHandlers.set('ALERTS_ALERTS_DELETED', (msg: ProcessedMessage) => { + const payload = extractPayload(msg) as { alertIds?: string[] }; + if (Array.isArray(payload?.alertIds)) { + for (const id of payload.alertIds) { + useAlertsStore.getState().removeOne(id); + } + } else { + warn('ALERTS_ALERTS_DELETED: unexpected payload structure', payload); + useConnectionStore.getState().incrementDiscarded(); + } + }); + // ============================================================================ // Accounting // ============================================================================ diff --git a/stores/settings.ts b/stores/settings.ts index 26cd8ff..80bbdc2 100644 --- a/stores/settings.ts +++ b/stores/settings.ts @@ -29,9 +29,11 @@ export interface FioConfig { } /** The reorderable Status-tab panels, in default display order. The company - * (cash) and attention panels are pinned above these and are not reorderable. */ -export type StatusPanelId = 'bases' | 'fleet' | 'contracts' | 'empire'; -export const STATUS_PANEL_IDS: StatusPanelId[] = ['bases', 'fleet', 'contracts', 'empire']; + * (cash) and attention panels are pinned above these and are not reorderable. + * reconcileOrder() appends ids missing from a persisted order, so adding a + * panel here needs no migration. */ +export type StatusPanelId = 'bases' | 'fleet' | 'contracts' | 'empire' | 'alerts'; +export const STATUS_PANEL_IDS: StatusPanelId[] = ['bases', 'fleet', 'contracts', 'empire', 'alerts']; interface SettingsState { burnThresholds: BurnThresholds; diff --git a/types/prun-api.ts b/types/prun-api.ts index 6efbe9f..737ab78 100644 --- a/types/prun-api.ts +++ b/types/prun-api.ts @@ -601,4 +601,112 @@ export namespace PrunApi { time: { timestamp: number }; cash: boolean; } + + // ============================================================================ + // Alert Types (the NOTS buffer's data — shapes from refined-prun's + // alerts.types.d.ts). Alerts carry NO display text: the game composes the + // string client-side from `type` + `data`, so APXM does the same + // (lib/format-alert.ts). + // ============================================================================ + + export interface Alert { + id: string; + type: AlertType; + contextId: string; + naturalId: string; + time: DateTime; + data: AlertData[]; + /** Server-side flags: seen = surfaced in the NOTS list, read = opened. */ + seen: boolean; + read: boolean; + } + + export type AlertType = + | 'ADMIN_CENTER_ELECTION_STARTED' + | 'ADMIN_CENTER_GOVERNOR_ELECTED' + | 'ADMIN_CENTER_MOTION_ENDED' + | 'ADMIN_CENTER_MOTION_PASSED' + | 'ADMIN_CENTER_MOTION_VOTING_STARTED' + | 'ADMIN_CENTER_NO_GOVERNOR_ELECTED' + | 'ADMIN_CENTER_RUN_SUCCEEDED' + | 'COGC_PROGRAM_CHANGED' + | 'COGC_STATUS_CHANGED' + | 'COGC_UPKEEP_STARTED' + | 'COMEX_ORDER_FILLED' + | 'COMEX_PICKUP_CONTRACT_CREATED' + | 'COMEX_TRADE' + | 'CONTRACT_CONDITION_FULFILLED' + | 'CONTRACT_CONTRACT_BREACHED' + | 'CONTRACT_CONTRACT_CANCELLED' + | 'CONTRACT_CONTRACT_CLOSED' + | 'CONTRACT_CONTRACT_EXTENDED' + | 'CONTRACT_CONTRACT_RECEIVED' + | 'CONTRACT_CONTRACT_REJECTED' + | 'CONTRACT_CONTRACT_TERMINATED' + | 'CONTRACT_CONTRACT_TERMINATION_REQUESTED' + | 'CONTRACT_DEADLINE_EXCEEDED_WITH_CONTROL' + | 'CONTRACT_DEADLINE_EXCEEDED_WITHOUT_CONTROL' + | 'CORPORATION_MANAGER_INVITE_ACCEPTED' + | 'CORPORATION_MANAGER_INVITE_REJECTED' + | 'CORPORATION_MANAGER_SHAREHOLDER_LEFT' + | 'CORPORATION_PROJECT_FINISHED' + | 'CORPORATION_SHAREHOLDER_DIVIDEND_RECEIVED' + | 'CORPORATION_SHAREHOLDER_INVITE_RECEIVED' + | 'FOREX_ORDER_FILLED' + | 'FOREX_TRADE' + | 'GATEWAY_JUMP_ABORTED_LINK_CHANGED' + | 'GATEWAY_JUMP_ABORTED_LINK_NOT_ESTABLISHED' + | 'GATEWAY_JUMP_ABORTED_MISSING_FUNDS' + | 'GATEWAY_JUMP_ABORTED_NO_CAPACITY' + | 'GATEWAY_JUMP_ABORTED_NO_FUEL' + | 'GATEWAY_JUMP_ABORTED_NOT_OPERATIONAL' + | 'GATEWAY_LINK_ESTABLISHED' + | 'GATEWAY_LINK_REQUEST_RECEIVED' + | 'GATEWAY_LINK_UNLINKED' + | 'INFRASTRUCTURE_OPERATIONAL_STATE_CHANGED' + | 'INFRASTRUCTURE_PROJECT_COMPLETED' + | 'INFRASTRUCTURE_UPGRADE_COMPLETED' + | 'INFRASTRUCTURE_UPKEEP_PHASE_STARTED' + | 'LOCAL_MARKET_AD_ACCEPTED' + | 'LOCAL_MARKET_AD_EXPIRED' + | 'PLANETARY_PROJECT_FINISHED' + | 'POPULATION_PROJECT_UPGRADED' + | 'POPULATION_REPORT_AVAILABLE' + | 'PRODUCTION_ORDER_FINISHED' + | 'RELEASE_NOTES' + | 'SHIP_FLIGHT_ENDED' + | 'SHIPYARD_PROJECT_FINISHED' + | 'SITE_EXPERT_DROPPED' + | 'TUTORIAL_TASK_FINISHED' + | 'USER_CONVERSION_REMINDER_LICENSE' + | 'USER_LICENSE_ABOUT_TO_EXPIRE' + | 'USER_LICENSE_EXPIRED' + | 'USER_LICENSE_GIFT_RECEIVED' + | 'USER_STEAM_REVIEW' + | 'WAREHOUSE_STORE_LOCKED_INSUFFICIENT_FUNDS' + | 'WAREHOUSE_STORE_UNLOCKED' + | 'WELCOME' + | 'WORKFORCE_LOW_SUPPLIES' + | 'WORKFORCE_OUT_OF_SUPPLIES' + | 'WORKFORCE_UNSATISFIED'; + + export type AlertData = + | { key: 'commodity'; value: string } + | { key: 'quantity'; value: number } + | { key: 'address'; value: { address: Address } } + | { key: 'material'; value: string } + | { key: 'expertiseCategory'; value: string } + | { key: 'planet'; value: { address: Address } } + | { key: 'program'; value: string } + | { key: 'destination'; value: { address: Address } } + | { key: 'registration'; value: string } + | { key: 'shipId'; value: string } + | { key: 'trades'; value: number } + | { key: 'partner'; value: ContractPartner } + | { key: 'contract'; value: string } + | { key: 'ticker'; value: string } + | { key: 'level'; value: number } + // Catch-all: exchange operators, admin-center motions, and any keys the + // game adds — the formatter treats unknown values as opaque. + | { key: string; value: unknown }; }