diff --git a/shims/vendor/poi-lib-battle.d.ts b/shims/vendor/poi-lib-battle.d.ts new file mode 100644 index 000000000..362f2b55c --- /dev/null +++ b/shims/vendor/poi-lib-battle.d.ts @@ -0,0 +1,19 @@ +declare module 'poi-lib-battle' { + export const Models: { + Battle: new (opts: { fleet: unknown; packet: unknown[] }) => { packet: unknown[] } + Fleet: new (opts: { type: number; main: unknown; escort?: unknown }) => unknown + [key: string]: unknown + } + + export const Simulator: { + auto: ( + battle: unknown, + opts?: unknown, + ) => { + mainFleet?: unknown[] + escortFleet?: unknown[] + enemyFleet?: unknown[] + enemyEscort?: unknown[] + } + } +} diff --git a/views/redux/__tests__/battle.spec.ts b/views/redux/__tests__/battle.spec.ts new file mode 100644 index 000000000..cf73e405b --- /dev/null +++ b/views/redux/__tests__/battle.spec.ts @@ -0,0 +1,689 @@ +import type { Reducer } from 'redux' +import { applyMiddleware, createStore } from 'redux' + +jest.mock('poi-lib-battle', () => { + class Fleet { + type: number + main: unknown + escort: unknown + + constructor(opts: { type: number; main: unknown; escort: unknown }) { + this.type = opts.type + this.main = opts.main + this.escort = opts.escort + } + } + + class Battle { + fleet: Fleet + packet: unknown[] + + constructor(opts: { fleet: Fleet; packet: unknown[] }) { + this.fleet = opts.fleet + this.packet = opts.packet + } + } + + const auto = (battle: Battle) => { + const main = Array.isArray(battle.fleet?.main) ? battle.fleet.main : [] + const escort = Array.isArray(battle.fleet?.escort) ? battle.fleet.escort : [] + + const toSimShip = (raw: unknown) => { + if (!raw || typeof raw !== 'object') return null + const r = raw as { api_id?: number; api_nowhp?: number; api_maxhp?: number } + return { + raw: { api_id: typeof r.api_id === 'number' ? r.api_id : -1 }, + nowHP: typeof r.api_nowhp === 'number' ? r.api_nowhp : 0, + initHP: typeof r.api_maxhp === 'number' ? r.api_maxhp : 0, + } + } + + return { + mainFleet: (main as unknown[]).map(toSimShip).filter(Boolean), + escortFleet: (escort as unknown[]).map(toSimShip).filter(Boolean), + enemyFleet: [{ id: 501, nowHP: 10 }], + enemyEscort: [], + } + } + + return { + Models: { Battle, Fleet }, + Simulator: { auto }, + } +}) + +import portFixture from 'views/redux/info/__tests__/__fixtures__/api_port_port_typical.json' +import mapStartFixture from 'views/redux/info/__tests__/__fixtures__/api_req_map_start_updates_event_gauge_hp.json' +import mapNextFixture from 'views/redux/info/__tests__/__fixtures__/api_req_map_next_with_itemget.json' +import battleResultFixture from 'views/redux/info/__tests__/__fixtures__/api_req_sortie_battleresult_includes_member_exp.json' + +import { battleSlice, dispatchBattleResult } from '../battle' +import { battleSliceMiddleware } from '../middlewares/battle-slice' + +type AnyAction = { type: string; [key: string]: unknown } + +type BattleSliceState = ReturnType +type TestRootState = Record & { battle: BattleSliceState } + +function createBattleStore(preloadedState: Record = {}) { + const initBattle = battleSlice.reducer(undefined, { type: '@@INIT' }) + const initialState: TestRootState = { + ...preloadedState, + battle: initBattle, + } + + const rootReducer: Reducer = (state = initialState, action) => ({ + ...state, + battle: battleSlice.reducer(state.battle, action), + }) + + return createStore(rootReducer, initialState, applyMiddleware(battleSliceMiddleware)) +} + +describe('views/redux/battleSlice + middleware', () => { + it('returns initial state', () => { + const store = createBattleStore({}) + const state = store.getState() + expect(state.battle.result.valid).toBe(false) + expect(state.battle._status.deckId).toBe(-1) + expect(state.battle._status.battle).toBe(null) + }) + + it('resets to initial state on api_port/port', () => { + const store = createBattleStore({ + sortie: { combinedFlag: 0, sortieStatus: [true] }, + info: { + fleets: { 0: { api_ship: [100] } }, + ships: { + 100: { + api_id: 100, + api_ship_id: 10, + api_nowhp: 9, + api_maxhp: 12, + api_slot: [], + }, + }, + }, + const: { $ships: { 10: { api_name: 'TestShip' } } }, + }) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + expect(store.getState().battle._status.battle).not.toBe(null) + + const init = battleSlice.reducer(undefined, { type: '@@INIT' }) + store.dispatch({ type: '@@Response/kcsapi/api_port/port', body: portFixture.body }) + expect(store.getState().battle).toEqual(init) + }) + + it('initializes map status on api_req_map/start', () => { + const store = createBattleStore({}) + store.dispatch({ + type: '@@Response/kcsapi/api_req_map/start', + body: mapStartFixture.body, + postBody: mapStartFixture.postBody, + }) + + const battle = store.getState().battle + expect(battle._status.map).toBe(493) + expect(battle._status.bossCell).toBe(23) + expect(battle._status.currentCell).toBe(31) + expect(battle._status.deckId).toBe(0) + expect(battle._status.colorNo).toBe(4) + expect(battle._status.enemyFormation).toBe(0) + expect(battle._status.battle).toBe(null) + }) + + it('updates route status on api_req_map/next and clears battle status', () => { + const store = createBattleStore({ + sortie: { combinedFlag: 0, sortieStatus: [true] }, + info: { + fleets: { 0: { api_ship: [100] } }, + ships: { + 100: { + api_id: 100, + api_ship_id: 10, + api_nowhp: 9, + api_maxhp: 12, + api_slot: [], + }, + }, + }, + const: { $ships: { 10: { api_name: 'TestShip' } } }, + }) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + + const state0 = store.getState() + expect(state0.battle._status.battle).not.toBe(null) + expect(state0.battle._status.enemyFormation).toBe(2) + + store.dispatch({ type: '@@Response/kcsapi/api_req_map/next', body: mapNextFixture.body }) + + const battle = store.getState().battle + expect(battle._status.currentCell).toBe(1) + expect(battle._status.colorNo).toBe(2) + expect(battle._status.enemyFormation).toBe(0) + expect(battle._status.battle).toBe(null) + }) + + it('records battle packets and preserves the first battle time', () => { + const store = createBattleStore({ + sortie: { combinedFlag: 1, sortieStatus: [true] }, + info: { + fleets: { 0: { api_ship: [100] } }, + ships: { + 100: { + api_id: 100, + api_ship_id: 10, + api_nowhp: 9, + api_maxhp: 12, + api_slot: [], + }, + }, + }, + const: { $ships: { 10: { api_name: 'TestShip' } } }, + }) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + + const battle1 = store.getState().battle + expect(battle1._status.time).toBe(123) + expect(battle1._status.enemyFormation).toBe(2) + expect(battle1._status.battle).not.toBe(null) + expect(battle1._status.battle && battle1._status.battle.packet).toHaveLength(1) + expect(battle1._status.battle && battle1._status.battle.packet[0]).toEqual( + expect.objectContaining({ poi_path: '/kcsapi/api_req_sortie/battle' }), + ) + expect(battle1._status.result?.deckShipId).toEqual([100]) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/airbattle', + path: '/kcsapi/api_req_sortie/airbattle', + time: 999, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + + const battle2 = store.getState().battle + expect(battle2._status.time).toBe(123) + expect(battle2._status.battle).not.toBe(null) + expect(battle2._status.battle && battle2._status.battle.packet).toHaveLength(2) + }) + + it('ignores battleresult when there is no intermediate battle result', () => { + const store = createBattleStore({}) + const before = store.getState().battle + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battleresult', + body: battleResultFixture.body, + }) + expect(store.getState().battle).toBe(before) + }) + + it('finalizes battle result on battleresult', () => { + const store = createBattleStore({ + sortie: { combinedFlag: 0, sortieStatus: [true] }, + info: { + fleets: { 0: { api_ship: [100] } }, + ships: { + 100: { + api_id: 100, + api_ship_id: 10, + api_nowhp: 9, + api_maxhp: 12, + api_slot: [], + }, + }, + }, + const: { $ships: { 10: { api_name: 'TestShip' } } }, + }) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_map/start', + body: mapStartFixture.body, + postBody: mapStartFixture.postBody, + }) + store.dispatch({ type: '@@Response/kcsapi/api_req_map/next', body: mapNextFixture.body }) + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battleresult', + body: battleResultFixture.body, + }) + + const state = store.getState().battle + expect(state.result.valid).toBe(true) + expect(state.result.rank).toBe('S') + expect(state.result.boss).toBe(false) + expect(state.result.map).toBe(493) + expect(state.result.mapCell).toBe(1) + expect(state.result.enemy).toBe('敵前衛部隊') + expect(state.result.mvp).toEqual([0, 0]) + expect(state._status.battle).toBe(null) + expect(state._status.time).toBe(0) + }) + + it('marks boss=true when currentCell equals bossCell', () => { + const store = createBattleStore({ + sortie: { combinedFlag: 0, sortieStatus: [true] }, + info: { + fleets: { 0: { api_ship: [100] } }, + ships: { + 100: { + api_id: 100, + api_ship_id: 10, + api_nowhp: 9, + api_maxhp: 12, + api_slot: [], + }, + }, + }, + const: { $ships: { 10: { api_name: 'TestShip' } } }, + }) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_map/start', + body: { + api_maparea_id: 1, + api_mapinfo_no: 1, + api_bosscell_no: 1, + api_no: 1, + api_color_no: 4, + }, + postBody: { api_deck_id: '1' }, + }) + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battleresult', + body: battleResultFixture.body, + }) + + expect(store.getState().battle.result.boss).toBe(true) + }) + + it('marks boss=true when colorNo is 5', () => { + const store = createBattleStore({ + sortie: { combinedFlag: 0, sortieStatus: [true] }, + info: { + fleets: { 0: { api_ship: [100] } }, + ships: { + 100: { + api_id: 100, + api_ship_id: 10, + api_nowhp: 9, + api_maxhp: 12, + api_slot: [], + }, + }, + }, + const: { $ships: { 10: { api_name: 'TestShip' } } }, + }) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_map/start', + body: { + api_maparea_id: 1, + api_mapinfo_no: 1, + api_bosscell_no: 99, + api_no: 1, + api_color_no: 4, + }, + postBody: { api_deck_id: '1' }, + }) + store.dispatch({ + type: '@@Response/kcsapi/api_req_map/next', + body: { api_no: 2, api_color_no: 5 }, + }) + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battleresult', + body: battleResultFixture.body, + }) + + expect(store.getState().battle.result.boss).toBe(true) + }) + + it('finalizes combined battle MVP correctly', () => { + const store = createBattleStore({ + sortie: { combinedFlag: 1, sortieStatus: [true, true] }, + info: { + fleets: { + 0: { api_ship: [100] }, + 1: { api_ship: [200] }, + }, + ships: { + 100: { api_id: 100, api_ship_id: 10, api_nowhp: 9, api_maxhp: 12, api_slot: [] }, + 200: { api_id: 200, api_ship_id: 20, api_nowhp: 8, api_maxhp: 11, api_slot: [] }, + }, + }, + const: { $ships: { 10: { api_name: 'A' }, 20: { api_name: 'B' } } }, + }) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battleresult', + body: { + api_win_rank: 'A', + api_quest_name: 'q', + api_enemy_info: { api_deck_name: 'e' }, + api_mvp: 1, + api_mvp_combined: 2, + api_get_useitem: null, + api_get_ship: null, + api_get_eventitem: null, + }, + }) + + expect(store.getState().battle.result.combined).toBe(true) + expect(store.getState().battle.result.mvp).toEqual([0, 1]) + }) + + it('uses api_dock_id as deck selector when api_deck_id is missing', () => { + const store = createBattleStore({ + sortie: { combinedFlag: 0, sortieStatus: [true] }, + info: { + fleets: { 0: { api_ship: [100] } }, + ships: { + 100: { + api_id: 100, + api_ship_id: 10, + api_nowhp: 9, + api_maxhp: 12, + api_slot: [], + }, + }, + }, + const: { $ships: { 10: { api_name: 'TestShip' } } }, + }) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_dock_id: 1 }, + }) + + expect(store.getState().battle._status.result?.deckShipId).toEqual([100]) + }) + + it('applies combinedFlag only when sortieStatus indicates 2 fleets', () => { + const baseState = { + sortie: { combinedFlag: 1, sortieStatus: [true] }, + info: { + fleets: { 0: { api_ship: [100] }, 1: { api_ship: [200] } }, + ships: { + 100: { api_id: 100, api_ship_id: 10, api_nowhp: 9, api_maxhp: 12, api_slot: [] }, + 200: { api_id: 200, api_ship_id: 20, api_nowhp: 8, api_maxhp: 11, api_slot: [] }, + }, + }, + const: { $ships: { 10: { api_name: 'A' }, 20: { api_name: 'B' } } }, + } + + const storeSingle = createBattleStore(baseState) + storeSingle.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + expect(storeSingle.getState().battle._status.result?.deckShipId).toEqual([100]) + + const storeCombined = createBattleStore({ + ...baseState, + sortie: { combinedFlag: 1, sortieStatus: [true, true] }, + }) + storeCombined.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + expect(storeCombined.getState().battle._status.result?.deckShipId).toEqual([100, 200]) + }) + + it('preserves enemyFormation when api_formation is missing', () => { + const store = createBattleStore({ + sortie: { combinedFlag: 0, sortieStatus: [true] }, + info: { + fleets: { 0: { api_ship: [100] } }, + ships: { + 100: { + api_id: 100, + api_ship_id: 10, + api_nowhp: 9, + api_maxhp: 12, + api_slot: [], + }, + }, + }, + const: { $ships: { 10: { api_name: 'TestShip' } } }, + }) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + expect(store.getState().battle._status.enemyFormation).toBe(2) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_combined_battle/ec_night_to_day', + path: '/kcsapi/api_req_combined_battle/ec_night_to_day', + time: 999, + body: { api_deck_id: 1 }, + }) + expect(store.getState().battle._status.enemyFormation).toBe(2) + }) + + it('hydrates fleet ships with poi_slot/poi_slot_ex and strips api_info', () => { + const store = createBattleStore({ + sortie: { combinedFlag: 0, sortieStatus: [true] }, + info: { + fleets: { 0: { api_ship: [100] } }, + ships: { + 100: { + api_id: 100, + api_ship_id: 10, + api_nowhp: 9, + api_maxhp: 12, + api_slot: [300, 999], + api_slot_ex: 301, + api_yomi: 'x', + }, + }, + equips: { + 300: { api_slotitem_id: 900, api_info: { x: 1 } }, + 301: { api_slotitem_id: 901, api_info: { y: 2 } }, + }, + }, + const: { + $ships: { 10: { api_name: 'TestShip' } }, + $equips: { + 900: { api_name: 'MainGun' }, + 901: { api_name: 'ExGun' }, + }, + }, + }) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + + const st = store.getState().battle + expect(st._status.battle).not.toBe(null) + + const battle = st._status.battle + const fleet = + battle && typeof battle === 'object' && 'fleet' in battle ? battle.fleet : undefined + const main = fleet && typeof fleet === 'object' && 'main' in fleet ? fleet.main : undefined + expect(Array.isArray(main)).toBe(true) + + const ship = Array.isArray(main) ? (main[0] as Record) : {} + expect(ship.poi_slot).toEqual([expect.objectContaining({ api_name: 'MainGun' }), null]) + expect(ship.poi_slot_ex).toEqual(expect.objectContaining({ api_name: 'ExGun' })) + expect(((ship.poi_slot as unknown[])[0] as Record).api_info).toBeUndefined() + expect((ship.poi_slot_ex as Record).api_info).toBeUndefined() + expect(ship.api_slot).toBeUndefined() + expect(ship.api_slot_ex).toBeUndefined() + expect(ship.api_yomi).toBeUndefined() + }) + + it('handles invalid fleet shape (api_ship not array)', () => { + const store = createBattleStore({ + sortie: { combinedFlag: 0, sortieStatus: [true] }, + info: { + fleets: { 0: { api_ship: 123 } }, + ships: {}, + }, + const: { $ships: {} }, + }) + + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + + expect(store.getState().battle._status.result?.deckShipId).toEqual([]) + }) + + it('uses post-reduction rootState when other reducers update on the same action', () => { + type RootState = { + info?: { fleets?: Record; ships?: Record } + const?: { $ships?: Record } + sortie?: { combinedFlag?: number; sortieStatus?: boolean[] } + battle?: BattleSliceState + } + + const initBattle = battleSlice.reducer(undefined, { type: '@@INIT' }) + const initialState: RootState = { + info: { fleets: {}, ships: {} }, + const: { $ships: { 10: { api_name: 'TestShip' } } }, + sortie: { combinedFlag: 0, sortieStatus: [true] }, + battle: initBattle, + } + + const rootReducer: Reducer = (state = initialState, action) => { + const nextState: RootState = { ...state } + + // Simulate another reducer updating info state on the same API action. + if (action.type === '@@Response/kcsapi/api_req_sortie/battle') { + nextState.info = { + fleets: { 0: { api_ship: [100] } }, + ships: { + 100: { + api_id: 100, + api_ship_id: 10, + api_nowhp: 9, + api_maxhp: 12, + api_slot: [], + }, + }, + } + } + + nextState.battle = battleSlice.reducer(state.battle!, action) + return nextState + } + + const store = createStore(rootReducer, initialState, applyMiddleware(battleSliceMiddleware)) + store.dispatch({ + type: '@@Response/kcsapi/api_req_sortie/battle', + path: '/kcsapi/api_req_sortie/battle', + time: 123, + body: { api_formation: [1, 2, 3], api_deck_id: 1 }, + }) + + expect((store.getState().battle as BattleSliceState)._status.result?.deckShipId).toEqual([100]) + }) +}) + +describe('dispatchBattleResult', () => { + it('does nothing when result is invalid', () => { + const dispatch = jest.fn() + + const win = { dispatchEvent: jest.fn() } + // @ts-expect-error jest env is not a browser; provide minimal window stub + globalThis.window = win + + dispatchBattleResult(dispatch, { + valid: false, + deckShipId: [], + deckHp: [], + deckInitHp: [], + enemyShipId: [], + enemyHp: [], + }) + + expect(dispatch).not.toHaveBeenCalled() + expect(win.dispatchEvent).not.toHaveBeenCalled() + }) + + it('dispatches @@BattleResult and a browser event for valid result', () => { + const dispatch = jest.fn() + + const win = { dispatchEvent: jest.fn() } + // @ts-expect-error jest env is not a browser; provide minimal window stub + globalThis.window = win + + const result = { + valid: true, + deckShipId: [1], + deckHp: [1], + deckInitHp: [2], + enemyShipId: [501], + enemyHp: [10], + } + + dispatchBattleResult(dispatch, result) + + expect(dispatch).toHaveBeenCalledWith({ + type: '@@BattleResult', + result, + }) + expect(win.dispatchEvent).toHaveBeenCalledTimes(1) + expect(win.dispatchEvent.mock.calls[0][0]).toEqual( + expect.objectContaining({ + detail: result, + }), + ) + }) +}) diff --git a/views/redux/battle.es b/views/redux/battle.es deleted file mode 100644 index d27a4d694..000000000 --- a/views/redux/battle.es +++ /dev/null @@ -1,250 +0,0 @@ -import { Models, Simulator } from 'poi-lib-battle' -const { Battle, Fleet } = Models -import { get } from 'lodash' - -function simulate(battle) { - const simulator = Simulator.auto(battle) - - const deckShipId = [], - deckHp = [], - deckInitHp = [] - const deck = [].concat(simulator.mainFleet || [], simulator.escortFleet || []) - deck.map((ship) => { - deckShipId.push(ship && ship.raw ? ship.raw.api_id : -1) // use _ships id in deckShipId - deckHp.push(ship ? ship.nowHP : 0) - deckInitHp.push(ship ? ship.initHP : 0) - }) - const enemyShipId = [], - enemyHp = [] - const enemy = [].concat(simulator.enemyFleet || [], simulator.enemyEscort || []) - enemy.map((ship) => { - enemyShipId.push(ship ? ship.id : -1) - enemyHp.push(ship ? ship.nowHP : 0) - }) - - return { - deckShipId: deckShipId, - deckHp: deckHp, - deckInitHp: deckInitHp, - enemyShipId: enemyShipId, - enemyHp: enemyHp, - } -} - -function getItem(itemId, state) { - const _item = get(state, `info.equips.${itemId}`) - const item = _item - ? { - ...get(state, `const.$equips.${_item.api_slotitem_id}`), - ..._item, - } - : null - if (item) { - // Clean up - delete item.api_info - } - return item -} - -function getShip(shipId, state) { - const _ship = get(state, `info.ships.${shipId}`) - const ship = _ship - ? { - ...get(state, `const.$ships.${_ship.api_ship_id}`), - ..._ship, - } - : null - if (ship) { - ship.poi_slot = [] - for (const id of ship.api_slot) { - ship.poi_slot.push(getItem(id, state)) - } - ship.poi_slot_ex = getItem(ship.api_slot_ex, state) - // Clean up - delete ship.api_getmes - delete ship.api_slot - delete ship.api_slot_ex - delete ship.api_yomi - } - return ship -} - -function getFleet(deckId, state) { - const deck = get(state, `info.fleets.${deckId - 1}`) || {} - const ships = deck.api_ship - if (ships) { - const fleet = [] - for (const id of ships) { - fleet.push(getShip(id, state)) - } - return fleet - } else { - return null - } -} - -function getSortieType(state) { - const combinedFlag = get(state, 'sortie.combinedFlag') - const sortieFleet = [] - for (const [i, status] of (get(state, 'sortie.sortieStatus') || []).entries()) { - if (status) sortieFleet.push(i) - } - return sortieFleet.length === 2 ? combinedFlag : 0 -} - -const statusInitState = { - deckId: -1, - map: -1, - bossCell: -1, - currentCell: -1, - enemyFormation: 0, // Formation: 0 - 単縦陣, 1 - 複縦陣, 2 - 輪形陣, 3 - 梯形陣, 4 - 単横陣, - // 5 - 第一警戒航行序列, 6 - 第二警戒航行序列, 7 - 第三警戒航行序列, 8 - 第四警戒航行序列 - colorNo: -1, - packet: [], - battle: null, - time: 0, -} - -const resultInitState = { - valid: false, -} - -const initState = { - // _status: Temporary middle results - _status: statusInitState, - // result: The result of a completed battle. Only changes on battle completion - result: resultInitState, -} - -export function reducer(state = initState, { type, path, body, postBody, time }, store) { - const { _status } = state - switch (type) { - case '@@Response/kcsapi/api_port/port': - // Initialize all info - return initState - case '@@Response/kcsapi/api_req_map/start': - // Refresh current map info - return { - ...state, - _status: { - ..._status, - battle: null, - map: body.api_maparea_id * 10 + body.api_mapinfo_no, - bossCell: body.api_bosscell_no, - currentCell: body.api_no, - deckId: parseInt(postBody.api_deck_id) - 1, - colorNo: body.api_color_no, - enemyFormation: 0, - }, - } - case '@@Response/kcsapi/api_req_map/next': - return { - ...state, - _status: { - ..._status, - currentCell: body.api_no, - battle: null, - colorNo: body.api_color_no, - enemyFormation: 0, - }, - } - // Normal battle - case '@@Response/kcsapi/api_req_sortie/battle': - case '@@Response/kcsapi/api_req_sortie/airbattle': - case '@@Response/kcsapi/api_req_sortie/ld_airbattle': - case '@@Response/kcsapi/api_req_combined_battle/battle': - case '@@Response/kcsapi/api_req_combined_battle/battle_water': - case '@@Response/kcsapi/api_req_combined_battle/airbattle': - case '@@Response/kcsapi/api_req_combined_battle/ld_airbattle': - case '@@Response/kcsapi/api_req_combined_battle/ec_battle': - case '@@Response/kcsapi/api_req_combined_battle/each_battle': - case '@@Response/kcsapi/api_req_combined_battle/each_battle_water': - case '@@Response/kcsapi/api_req_battle_midnight/battle': - case '@@Response/kcsapi/api_req_battle_midnight/sp_midnight': - case '@@Response/kcsapi/api_req_combined_battle/midnight_battle': - case '@@Response/kcsapi/api_req_combined_battle/sp_midnight': - case '@@Response/kcsapi/api_req_combined_battle/ec_midnight_battle': - case '@@Response/kcsapi/api_req_combined_battle/ec_night_to_day': { - const sortieTypeFlag = getSortieType(store) - const enemyFormation = (body.api_formation || [])[1] || _status.enemyFormation - const fleetId = [body.api_deck_id, body.api_dock_id].find((x) => x != null) - const escortId = sortieTypeFlag > 0 ? 2 : -1 - const battle = _status.battle - ? _status.battle - : new Battle({ - fleet: new Fleet({ - type: sortieTypeFlag, - main: getFleet(fleetId, store), - escort: getFleet(escortId, store), - }), - packet: [], - }) - const packet = Object.clone(body) - packet.poi_path = path - battle.packet.push(packet) - const result = simulate(battle) - - return { - ...state, - _status: { - ..._status, - battle, - result, - enemyFormation, - time: _status.time ? _status.time : time, - }, - } - } - case '@@Response/kcsapi/api_req_sortie/battleresult': - case '@@Response/kcsapi/api_req_combined_battle/battleresult': - if (_status.result) { - const result = { - ..._status.result, - valid: true, - time: _status.time, - rank: body.api_win_rank, - boss: _status.bossCell == _status.currentCell || _status.colorNo == 5, - map: _status.map, - mapCell: _status.currentCell, - quest: body.api_quest_name, - enemy: body.api_enemy_info.api_deck_name, - combined: getSortieType(store) > 0, - mvp: - getSortieType(store) > 0 - ? [body.api_mvp - 1, body.api_mvp_combined - 1] - : [body.api_mvp - 1, body.api_mvp - 1], - dropItem: body.api_get_useitem, - dropShipId: body.api_get_ship != null ? body.api_get_ship.api_ship_id : -1, - enemyFormation: _status.enemyFormation, - eventItem: body.api_get_eventitem, - } - return { - ...state, - result, - _status: { - ..._status, - battle: null, - time: 0, - }, - } - } - break - } - return state -} - -// Subscriber, used on battle completion. -// Need to observe on state battle.result -export function dispatchBattleResult(dispatch, battleResult, oldBattleResult) { - if (!battleResult.valid) return - dispatch({ - type: '@@BattleResult', - result: battleResult, - }) - const e = new CustomEvent('battle.result', { - bubbles: true, - cancelable: true, - detail: battleResult, - }) - window.dispatchEvent(e) -} diff --git a/views/redux/battle.ts b/views/redux/battle.ts new file mode 100644 index 000000000..d39fe47b5 --- /dev/null +++ b/views/redux/battle.ts @@ -0,0 +1,389 @@ +import { get } from 'lodash' +import type { Dispatch } from 'redux' +import { createSlice } from '@reduxjs/toolkit' + +import { Models, Simulator } from 'poi-lib-battle' + +const { Battle, Fleet } = Models + +function jsonCloneObject(obj: Record): Record { + return JSON.parse(JSON.stringify(obj)) as Record +} + +type EquipInfo = { + api_slotitem_id: number + api_info?: unknown + [key: string]: unknown +} + +type ShipInfo = { + api_id: number + api_ship_id: number + api_nowhp: number + api_maxhp: number + api_slot?: number[] + api_slot_ex?: number + [key: string]: unknown +} + +type RootState = { + info?: { + equips?: Record + ships?: Record + fleets?: Record + } + const?: { + $equips?: Record> + $ships?: Record> + } + sortie?: { + combinedFlag?: number + sortieStatus?: boolean[] + } +} + +export type BattleSimulationResult = { + deckShipId: number[] + deckHp: number[] + deckInitHp: number[] + enemyShipId: number[] + enemyHp: number[] +} + +export type BattleResult = BattleSimulationResult & { + valid: boolean + time?: number + rank?: string + boss?: boolean + map?: number + mapCell?: number + quest?: string + enemy?: string + combined?: boolean + mvp?: [number, number] + dropItem?: unknown + dropShipId?: number + enemyFormation?: number + eventItem?: unknown +} + +type StatusState = { + deckId: number + map: number + bossCell: number + currentCell: number + enemyFormation: number + colorNo: number + packet: unknown[] + battle: InstanceType<(typeof Models)['Battle']> | null + result?: BattleSimulationResult + time: number +} + +export type BattleState = { + _status: StatusState + result: BattleResult +} + +function simulate(battle: InstanceType<(typeof Models)['Battle']>): BattleSimulationResult { + const simulator = Simulator.auto(battle) + const deckShipId: number[] = [] + const deckHp: number[] = [] + const deckInitHp: number[] = [] + + const deck = ( + [] as Array<(typeof simulator)['mainFleet'] extends Array ? S : unknown> + ).concat(simulator?.mainFleet || [], simulator?.escortFleet || []) + + deck.forEach((ship) => { + const raw = + ship && typeof ship === 'object' ? (ship as { raw?: { api_id?: number } }).raw : undefined + deckShipId.push(raw && typeof raw.api_id === 'number' ? raw.api_id : -1) + + const nowHP = ship && typeof ship === 'object' ? (ship as { nowHP?: number }).nowHP : undefined + deckHp.push(typeof nowHP === 'number' ? nowHP : 0) + + const initHP = + ship && typeof ship === 'object' ? (ship as { initHP?: number }).initHP : undefined + deckInitHp.push(typeof initHP === 'number' ? initHP : 0) + }) + + const enemyShipId: number[] = [] + const enemyHp: number[] = [] + const enemy = ([] as unknown[]).concat(simulator?.enemyFleet || [], simulator?.enemyEscort || []) + enemy.forEach((ship) => { + const id = ship && typeof ship === 'object' ? (ship as { id?: number }).id : undefined + enemyShipId.push(typeof id === 'number' ? id : -1) + + const nowHP = ship && typeof ship === 'object' ? (ship as { nowHP?: number }).nowHP : undefined + enemyHp.push(typeof nowHP === 'number' ? nowHP : 0) + }) + + return { deckShipId, deckHp, deckInitHp, enemyShipId, enemyHp } +} + +function getItem(itemId: number, state: RootState): Record | null { + const _item = get(state, `info.equips.${itemId}`) as EquipInfo | undefined + const item = _item + ? ({ + ...(get(state, `const.$equips.${_item.api_slotitem_id}`) as + | Record + | undefined), + ..._item, + } as Record) + : null + + if (item) { + // Clean up + delete item['api_info'] + } + + return item +} + +function getShip(shipId: number, state: RootState): Record | null { + const _ship = get(state, `info.ships.${shipId}`) as ShipInfo | undefined + const ship = _ship + ? ({ + ...(get(state, `const.$ships.${_ship.api_ship_id}`) as Record | undefined), + ..._ship, + } as Record) + : null + + if (ship) { + ship['poi_slot'] = [] + const api_slot = (ship['api_slot'] as number[] | undefined) || [] + for (const id of api_slot) { + ;(ship['poi_slot'] as unknown[]).push(getItem(id, state)) + } + + const api_slot_ex = + typeof ship['api_slot_ex'] === 'number' ? (ship['api_slot_ex'] as number) : -1 + ship['poi_slot_ex'] = api_slot_ex > 0 ? getItem(api_slot_ex, state) : null + + // Clean up + delete ship['api_getmes'] + delete ship['api_slot'] + delete ship['api_slot_ex'] + delete ship['api_yomi'] + } + + return ship +} + +function getFleet( + deckId: number | undefined, + state: RootState, +): Array | null> | null { + if (typeof deckId !== 'number' || !Number.isFinite(deckId) || deckId <= 0) { + return null + } + + const deck = + (get(state, `info.fleets.${deckId - 1}`) as { api_ship?: number[] } | undefined) || {} + const ships = deck.api_ship + if (!Array.isArray(ships)) { + return null + } + + const fleet: Array | null> = [] + for (const id of ships) { + fleet.push(typeof id === 'number' && id > 0 ? getShip(id, state) : null) + } + return fleet +} + +function getSortieType(state: RootState): number { + const combinedFlag = Number(get(state, 'sortie.combinedFlag')) || 0 + const sortieFleet: number[] = [] + for (const [i, status] of ( + (get(state, 'sortie.sortieStatus') as boolean[] | undefined) || [] + ).entries()) { + if (status) sortieFleet.push(i) + } + return sortieFleet.length === 2 ? combinedFlag : 0 +} + +const statusInitState: StatusState = { + deckId: -1, + map: -1, + bossCell: -1, + currentCell: -1, + enemyFormation: 0, + // Formation: 0 - 単縦陣, 1 - 複縦陣, 2 - 輪形陣, 3 - 梯形陣, 4 - 単横陣, + // 5 - 第一警戒航行序列, 6 - 第二警戒航行序列, 7 - 第三警戒航行序列, 8 - 第四警戒航行序列 + colorNo: -1, + packet: [], + battle: null, + time: 0, +} + +const resultInitState: BattleResult = { + valid: false, + deckShipId: [], + deckHp: [], + deckInitHp: [], + enemyShipId: [], + enemyHp: [], +} + +const initState: BattleState = { + // _status: Temporary middle results + _status: statusInitState, + // result: The result of a completed battle. Only changes on battle completion + result: resultInitState, +} + +type MapStartBody = { + api_maparea_id: number + api_mapinfo_no: number + api_bosscell_no: number + api_no: number + api_color_no: number +} + +type MapStartPostBody = { + api_deck_id: string +} + +type MapNextBody = { + api_no: number + api_color_no: number +} + +type BattleBody = { + api_formation?: number[] + api_deck_id?: number + api_dock_id?: number + [key: string]: unknown +} + +type BattleResultBody = { + api_win_rank: string + api_quest_name: string + api_enemy_info: { api_deck_name: string } + api_mvp: number + api_mvp_combined?: number + api_get_useitem?: unknown + api_get_ship?: { api_ship_id?: number } | null + api_get_eventitem?: unknown +} + +export const battleSlice = createSlice({ + name: 'battle', + initialState: initState, + reducers: { + port: () => initState, + + mapStart: (state, action: { payload: { body: unknown; postBody: unknown } }) => { + const body = action.payload.body as MapStartBody + const postBody = action.payload.postBody as MapStartPostBody + const deckId = Number.parseInt(String(postBody.api_deck_id || ''), 10) + + state._status.battle = null + state._status.map = Number(body.api_maparea_id) * 10 + Number(body.api_mapinfo_no) + state._status.bossCell = Number(body.api_bosscell_no) + state._status.currentCell = Number(body.api_no) + state._status.deckId = Number.isFinite(deckId) ? deckId - 1 : -1 + state._status.colorNo = Number(body.api_color_no) + state._status.enemyFormation = 0 + }, + + mapNext: (state, action: { payload: { body: unknown } }) => { + const body = action.payload.body as MapNextBody + state._status.currentCell = Number(body.api_no) + state._status.battle = null + state._status.colorNo = Number(body.api_color_no) + state._status.enemyFormation = 0 + }, + + battle: ( + state, + action: { payload: { body: unknown; path?: string; time?: number; rootState: unknown } }, + ) => { + const body = (action.payload.body || {}) as BattleBody + const upperState = (action.payload.rootState || {}) as RootState + + const sortieTypeFlag = getSortieType(upperState) + + const formation = body.api_formation || [] + const enemyFormation = (formation[1] || state._status.enemyFormation) as number + const fleetId = [body.api_deck_id, body.api_dock_id].find((x) => x != null) + const escortId = sortieTypeFlag > 0 ? 2 : -1 + + const battle = + state._status.battle || + new Battle({ + fleet: new Fleet({ + type: sortieTypeFlag, + main: getFleet(fleetId, upperState), + escort: getFleet(escortId, upperState), + }), + packet: [], + }) + + const packet = jsonCloneObject(body as Record) + packet.poi_path = String(action.payload.path || '') + battle.packet.push(packet) + + state._status.battle = battle + state._status.result = simulate(battle) + state._status.enemyFormation = enemyFormation + state._status.time = state._status.time + ? state._status.time + : Number(action.payload.time) || 0 + }, + + battleResult: (state, action: { payload: { body: unknown; rootState: unknown } }) => { + const body = action.payload.body as BattleResultBody + const upperState = (action.payload.rootState || {}) as RootState + + if (!state._status.result) return + + const combined = getSortieType(upperState) > 0 + const mvp1 = Number(body.api_mvp) - 1 + const mvp2 = combined ? Number(body.api_mvp_combined) - 1 : mvp1 + + state.result = { + ...state._status.result, + valid: true, + time: state._status.time, + rank: body.api_win_rank, + boss: state._status.bossCell === state._status.currentCell || state._status.colorNo === 5, + map: state._status.map, + mapCell: state._status.currentCell, + quest: body.api_quest_name, + enemy: body.api_enemy_info.api_deck_name, + combined, + mvp: [mvp1, mvp2], + dropItem: body.api_get_useitem, + dropShipId: + body.api_get_ship?.api_ship_id != null ? Number(body.api_get_ship.api_ship_id) : -1, + enemyFormation: state._status.enemyFormation, + eventItem: body.api_get_eventitem, + } + + state._status.battle = null + state._status.time = 0 + }, + }, +}) + +export const battleActions = battleSlice.actions + +// Subscriber, used on battle completion. +// Need to observe on state battle.result +export function dispatchBattleResult(dispatch: Dispatch, battleResult: BattleResult): void { + if (!battleResult.valid) return + dispatch({ + type: '@@BattleResult', + result: battleResult, + }) + + const e = new CustomEvent('battle.result', { + bubbles: true, + cancelable: true, + detail: battleResult, + }) + window.dispatchEvent(e) +} diff --git a/views/redux/create-store.es b/views/redux/create-store.es index b163a8351..53bd6a8a4 100644 --- a/views/redux/create-store.es +++ b/views/redux/create-store.es @@ -8,6 +8,7 @@ import { reducerFactory, onConfigChange } from './reducer-factory' import { saveQuestTracking, schedualDailyRefresh } from './info/quests' import { dockingCompleteObserver } from './info/repairs' import { dispatchBattleResult } from './battle' +import { battleSliceMiddleware } from './middlewares/battle-slice' import { resourcesCrossSliceMiddleware } from './middlewares/resources-cross-slice' import { equipsCrossSliceMiddleware } from './middlewares/equips-cross-slice' import { shipsCrossSliceMiddleware } from './middlewares/ships-cross-slice' @@ -64,6 +65,7 @@ export const store = createStore( composeEnhancers( applyMiddleware( thunk, + battleSliceMiddleware, resourcesCrossSliceMiddleware, equipsCrossSliceMiddleware, shipsCrossSliceMiddleware, diff --git a/views/redux/middlewares/battle-slice.ts b/views/redux/middlewares/battle-slice.ts new file mode 100644 index 000000000..023374ddd --- /dev/null +++ b/views/redux/middlewares/battle-slice.ts @@ -0,0 +1,76 @@ +import type { Middleware } from 'redux' + +import { battleActions } from '../battle' + +type AnyAction = { + type: string + path?: string + body?: unknown + postBody?: unknown + time?: number +} + +export const battleSliceMiddleware: Middleware = (store) => (next) => (action) => { + const a = action as AnyAction + + // Let reducers/middlewares process the original API response first so any + // upstream state (info/sortie/etc.) is up to date when building battle state. + const result = next(action) + + switch (a.type) { + case '@@Response/kcsapi/api_port/port': + store.dispatch(battleActions.port()) + break + + case '@@Response/kcsapi/api_req_map/start': + store.dispatch( + battleActions.mapStart({ + body: a.body, + postBody: a.postBody, + }), + ) + break + + case '@@Response/kcsapi/api_req_map/next': + store.dispatch(battleActions.mapNext({ body: a.body })) + break + + case '@@Response/kcsapi/api_req_sortie/battle': + case '@@Response/kcsapi/api_req_sortie/airbattle': + case '@@Response/kcsapi/api_req_sortie/ld_airbattle': + case '@@Response/kcsapi/api_req_combined_battle/battle': + case '@@Response/kcsapi/api_req_combined_battle/battle_water': + case '@@Response/kcsapi/api_req_combined_battle/airbattle': + case '@@Response/kcsapi/api_req_combined_battle/ld_airbattle': + case '@@Response/kcsapi/api_req_combined_battle/ec_battle': + case '@@Response/kcsapi/api_req_combined_battle/each_battle': + case '@@Response/kcsapi/api_req_combined_battle/each_battle_water': + case '@@Response/kcsapi/api_req_battle_midnight/battle': + case '@@Response/kcsapi/api_req_battle_midnight/sp_midnight': + case '@@Response/kcsapi/api_req_combined_battle/midnight_battle': + case '@@Response/kcsapi/api_req_combined_battle/sp_midnight': + case '@@Response/kcsapi/api_req_combined_battle/ec_midnight_battle': + case '@@Response/kcsapi/api_req_combined_battle/ec_night_to_day': + store.dispatch( + battleActions.battle({ + body: a.body, + path: a.path, + time: a.time, + rootState: store.getState() as unknown, + }), + ) + break + + case '@@Response/kcsapi/api_req_sortie/battleresult': + case '@@Response/kcsapi/api_req_combined_battle/battleresult': + store.dispatch( + battleActions.battleResult({ + body: a.body, + rootState: store.getState() as unknown, + }), + ) + break + } + + return result +} diff --git a/views/redux/reducer-factory.es b/views/redux/reducer-factory.es index c3bd82d62..959b05182 100644 --- a/views/redux/reducer-factory.es +++ b/views/redux/reducer-factory.es @@ -7,7 +7,7 @@ import { reducer as sortie } from './sortie' import { reducer as timers } from './timers' import { reducer as config } from './config' import { reducer as layout } from './layout' -import { reducer as battle } from './battle' +import { battleSlice } from './battle' import { reducer as plugins } from './plugins' import { reducer as fcd } from './fcd' import { reducer as ui } from './ui' @@ -48,7 +48,7 @@ export function reducerFactory(extensionConfig) { sortie, timers, config, - battle, + battle: battleSlice.reducer, misc, fcd, plugins: window.isMain ? plugins : () => emptyObject,