From 291306c1a864a73e012a2e1b5b6d1c800b94561b Mon Sep 17 00:00:00 2001 From: KagamiChan <3816900+KagamiChan@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:15:01 +0800 Subject: [PATCH 1/4] refactor: unify transport point calculation --- src/utils/transport-adapter.test.ts | 100 ++++++++++ src/utils/transport-adapter.ts | 26 +++ src/utils/transport.test.ts | 276 ++++++++++++++-------------- src/utils/transport.ts | 184 +++++++------------ src/views/battle-view-area.tsx | 2 +- src/views/battle-view-model.test.ts | 32 ++++ src/views/battle-view-model.ts | 22 ++- 7 files changed, 379 insertions(+), 263 deletions(-) create mode 100644 src/utils/transport-adapter.test.ts create mode 100644 src/utils/transport-adapter.ts diff --git a/src/utils/transport-adapter.test.ts b/src/utils/transport-adapter.test.ts new file mode 100644 index 0000000..5985f5a --- /dev/null +++ b/src/utils/transport-adapter.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import type { Ship } from 'poi-lib-battle' +import type { APIGetMemberShip2Response } from 'kcsapi/api_get_member/ship2/response' +import type { APIMstShip, APIMstSlotitem } from 'kcsapi/api_start2/getData/response' + +import { transformToLibBattleClass } from './lib-battle-adapter' +import { calculateTransport } from './transport' +import { toTransportFleets } from './transport-adapter' + +const memberShip = { + api_id: 101, + api_ship_id: 1, + api_nowhp: 10, + api_maxhp: 20, + api_slot_ex: 201, + api_kyouka: [0, 0, 0, 0], + api_karyoku: [0], + api_raisou: [0], + api_taiku: [0], + api_soukou: [0], +} as APIGetMemberShip2Response + +const masterShip = { api_id: 1, api_stype: 2 } as APIMstShip + +const memberItem = (instanceId: number, itemId: number) => ({ + api_id: instanceId, + api_slotitem_id: itemId, + api_level: 0, + api_locked: 0, +}) + +const masterItem = (itemId: number, category: number) => ({ + api_id: itemId, + api_name: String(itemId), + api_type: [0, 0, category, 0, 0], +}) as APIMstSlotitem + +describe('toTransportFleets', () => { + it('preserves member/master equipment data and counts expansion-slot cargo once', () => { + const fleets = transformToLibBattleClass( + [[[memberShip, masterShip], null], []], + [[[ + [memberItem(200, 576), masterItem(576, 24), undefined], + [memberItem(201, 167), masterItem(167, 46), undefined], + null, + ]], []], + ) + + const normalized = toTransportFleets(fleets) + expect(normalized).toEqual([[ + { + shipId: 1, + shipType: 2, + eligible: true, + items: [ + { itemId: 576, category: 24 }, + { itemId: 167, category: 46 }, + ], + }, + ], []]) + expect(calculateTransport(normalized)).toEqual({ + planned: 15, deliverable: 15, hasTransportCargo: true, + }) + // Destroyer 3.75 + R35 24 + Ka-Mi 12.5 = 40.25. + expect(calculateTransport(normalized, 'tank')).toEqual({ + planned: 40, deliverable: 40, hasTransportCargo: true, + }) + expect(toTransportFleets(fleets, [101])[0][0].eligible).toBe(false) + // Retreat IDs are member IDs, not master ship IDs. + expect(toTransportFleets(fleets, [1])[0][0].eligible).toBe(true) + }) + + it.each([[4, false], [5, false], [6, true]])( + 'preserves the HP eligibility threshold at %i / 20 HP', + (api_nowhp, eligible) => { + const fleets = transformToLibBattleClass( + [[[{ ...memberShip, api_nowhp }, masterShip]]], + [], + ) + expect(toTransportFleets(fleets)[0][0].eligible).toBe(eligible) + }, + ) + + it('tolerates empty fleets, empty slots and incomplete raw data', () => { + const ship = { + raw: { api_stype: 2, poi_slot: [null, {}] }, + } as Ship + expect(toTransportFleets([[null, undefined], [ship]])).toEqual([ + [], + [{ + shipId: -1, + shipType: 2, + eligible: false, + items: [{ itemId: -1, category: -1 }], + }], + ]) + expect(toTransportFleets([[{ raw: { api_stype: 2 } } as Ship]])[0][0].items) + .toEqual([]) + }) +}) diff --git a/src/utils/transport-adapter.ts b/src/utils/transport-adapter.ts new file mode 100644 index 0000000..77f448c --- /dev/null +++ b/src/utils/transport-adapter.ts @@ -0,0 +1,26 @@ +import type { Ship } from 'poi-lib-battle' +import type { FriendShipRaw } from '../views/ship-view/types' +import type { TransportShip } from './transport' + +/** Equipment master fields have already been merged by lib-battle-adapter. */ +export const toTransportFleets = ( + fleets: readonly (readonly (Ship | null | undefined)[])[], + escapedShipIds: readonly number[] = [], +): TransportShip[][] => fleets.map((fleet) => fleet + .filter((ship): ship is Ship => ship != null) + .map((ship) => { + const raw = ship.raw as FriendShipRaw + return { + shipId: raw.api_ship_id ?? -1, + shipType: raw.api_stype, + eligible: !escapedShipIds.includes(raw.api_id ?? -1) && + (raw.api_nowhp ?? 0) * 4 > (raw.api_maxhp ?? 0), + items: [...(raw.poi_slot ?? []), raw.poi_slot_ex] + .filter((item) => item != null) + .map((item) => ({ + itemId: item.api_slotitem_id ?? -1, + category: item.api_type?.[2] ?? -1, + })), + } + }), +) diff --git a/src/utils/transport.test.ts b/src/utils/transport.test.ts index c68d8b5..963ef95 100644 --- a/src/utils/transport.test.ts +++ b/src/utils/transport.test.ts @@ -1,124 +1,149 @@ import { describe, expect, it } from 'vitest' -import type { Ship } from 'poi-lib-battle' import { - getTransportPoint, - getTransportPointFromFleets, + calculateTransport, parseTankTransportMaps, + type TransportShip, } from './transport' -// the TP tables key off the equipment category in api_type[2] +// Categories for the two recorded fleet fixtures below. const landingCraft = new Set([68, 166, 193, 230, 355, 408, 409, 436, 449, 482, 494, 495, 514, 576]) const amphibious = new Set([167, 525, 526]) const tanks = new Set([496, 497, 498, 499]) const rations = new Set([145, 150, 241]) -const category = (apiSlotitemId: number): number => { - if (landingCraft.has(apiSlotitemId)) return 24 - if (amphibious.has(apiSlotitemId)) return 46 - if (tanks.has(apiSlotitemId)) return 52 - if (rations.has(apiSlotitemId)) return 43 - return apiSlotitemId === 75 ? 30 : 1 +const category = (itemId: number): number => { + if (landingCraft.has(itemId)) return 24 + if (amphibious.has(itemId)) return 46 + if (tanks.has(itemId)) return 52 + if (rations.has(itemId)) return 43 + return itemId === 75 ? 30 : 1 } -const slotItem = (apiSlotitemId: number, apiTypeId = category(apiSlotitemId)): ApiSlotItemLike => ({ - api_id: apiSlotitemId, - api_level: 0, - api_locked: 0, - api_slotitem_id: apiSlotitemId, - api_name: String(apiSlotitemId), - api_type: [0, 0, apiTypeId, 0, 0], -}) - -const equipSlots = (...items: ApiSlotItemLike[]): [ApiSlotItemLike][] => - items.map((item) => [item]) - -// [api_stype, api_ship_id, equipment ids] +// [ship type, master ship ID, equipment master IDs] type FleetFixture = [number, number, number[]][] -const shipsOf = (fleet: FleetFixture) => - fleet.map(([api_stype, api_ship_id], index) => ({ - api_id: index, - api_nowhp: 10, - api_maxhp: 10, - api_stype, - api_ship_id, +const shipsOf = (fleet: FleetFixture): TransportShip[] => + fleet.map(([shipType, shipId, items]) => ({ + shipType, + shipId, + eligible: true, + items: items.map((itemId) => ({ itemId, category: category(itemId) })), })) -const equipsOf = (fleet: FleetFixture) => - fleet.map(([, , items]) => equipSlots(...items.map((item) => slotItem(item)))) - -describe('transport point helpers', () => { - it('calculates total and actual TP from ship and equipment data', () => { - expect( - getTransportPoint( - [ - { api_id: 1, api_nowhp: 10, api_maxhp: 20, api_stype: 2, api_ship_id: 1 }, - { api_id: 2, api_nowhp: 4, api_maxhp: 20, api_stype: 3, api_ship_id: 487 }, - ], - [ - equipSlots(slotItem(75)), - equipSlots(slotItem(999)), - ], - ), - ).toEqual({ total: 20, actual: 10 }) +describe('calculateTransport', () => { + it('scores all ships for planned TP and eligible ships for deliverable TP', () => { + const ships = shipsOf([[2, 1, [75]], [3, 487, [999]]]) + ships[1].eligible = false + expect(calculateTransport([ships])).toEqual({ + planned: 20, deliverable: 10, hasTransportCargo: true, + }) + ships[0].eligible = false + expect(calculateTransport([ships]).deliverable).toBe(0) }) - it('excludes escaped ships from actual TP', () => { - expect( - getTransportPoint( - [{ api_id: 1, api_nowhp: 10, api_maxhp: 20, api_stype: 2, api_ship_id: 1 }], - [equipSlots(slotItem(75))], - [1], - ), - ).toEqual({ total: 10, actual: 0 }) + it('keeps ship capacity even when there is no transport cargo', () => { + expect(calculateTransport([shipsOf([[2, 1, []]])])).toEqual({ + planned: 5, deliverable: 5, hasTransportCargo: false, + }) + expect(calculateTransport([])).toEqual({ + planned: 0, deliverable: 0, hasTransportCargo: false, + }) + expect(calculateTransport([[]], 'tank')).toEqual({ + planned: 0, deliverable: 0, hasTransportCargo: false, + }) }) - it('returns zero total when no transport equipment exists', () => { - expect( - getTransportPoint( - [{ api_id: 1, api_nowhp: 10, api_maxhp: 20, api_stype: 2, api_ship_id: 1 }], - [[]], - ), - ).toEqual({ total: 0, actual: 5 }) + it.each([ + [2, 5], [3, 2], [21, 6], [6, 4], [10, 7], [16, 9], + [14, 1], [17, 12], [15, 15], [22, 15], [20, 7], [4, 0], [999, 0], + ])('scores ship type %i with base %i', (shipType, base) => { + const fleets = [shipsOf([[shipType, 1, []]])] + expect(calculateTransport(fleets).planned).toBe(base) + expect(calculateTransport(fleets, 'tank').planned).toBe(Math.floor(base * 0.75)) }) - it('scores equipment by category, so unknown landing craft still count', () => { - const ships = [{ api_id: 1, api_nowhp: 10, api_maxhp: 10, api_stype: 2, api_ship_id: 1 }] - const equips = [equipSlots(slotItem(9999, 24))] - - // 8 for the landing craft, 6 in tank mode, on top of the destroyer itself - expect(getTransportPoint(ships, equips)).toEqual({ total: 13, actual: 13 }) - expect(getTransportPoint(ships, equips, [], 'tank')).toEqual({ total: 9, actual: 9 }) + it.each([ + [576, 24, 8, 24], [514, 24, 8, 23], [449, 24, 8, 21], + [355, 24, 8, 20], [230, 24, 8, 19], [495, 24, 8, 19], + [482, 24, 8, 19], [494, 24, 8, 17], [436, 24, 8, 16], + [166, 24, 8, 14], [68, 24, 8, 6], [9999, 24, 8, 6], + [526, 46, 2, 13.5], [167, 46, 2, 12.5], [525, 46, 2, 11.5], + [499, 52, 0, 14], [498, 52, 0, 9], [497, 52, 0, 7], [496, 52, 0, 5], + [75, 30, 5, 3.75], [145, 43, 1, 0.75], [9999, 1, 0, 0], + ])('scores equipment %i in category %i', (itemId, category, normal, tank) => { + // Four copies expose fractional values without per-item rounding. + const ships: TransportShip[] = [{ + shipType: 1, shipId: 1, eligible: true, + items: Array.from({ length: 4 }, () => ({ itemId, category })), + }] + expect(calculateTransport([ships])).toEqual({ + planned: normal * 4, deliverable: normal * 4, hasTransportCargo: normal > 0, + }) + expect(calculateTransport([ships], 'tank')).toEqual({ + planned: tank * 4, deliverable: tank * 4, hasTransportCargo: tank > 0, + }) }) - it('get the equipment category from the master', () => { - const ships = [{ api_id: 114, api_nowhp: 514, api_maxhp: 666, api_stype: 1, api_ship_id: 325 }] - const memberItem = { api_id: 1, api_level: 0, api_locked: 0, api_slotitem_id: 576 } // R35 - const masterItem = { api_type: [0, 0, 24, 0, 0] } - const equip: [typeof memberItem, typeof masterItem] = [memberItem, masterItem] - const equips = [[equip]] - - expect(getTransportPoint(ships, equips)).toEqual({ total: 8, actual: 8 }) - expect(getTransportPoint(ships, equips, [], 'tank')).toEqual({ total: 24, actual: 24 }) + it('combines fractional ship and equipment contributions before rounding', () => { + const fleets = [shipsOf([[2, 1, [75, 167]]])] + expect(calculateTransport(fleets).planned).toBe(12) + // Destroyer 3.75 + drum 3.75 + Ka-Mi 12.5. + expect(calculateTransport(fleets, 'tank').planned).toBe(20) }) - it('adds the 鬼怒改二 bonus once, and does not scale it in tank mode', () => { - const kinu: FleetFixture = [ - [3, 487, [68]], - [2, 1, [68]], - ] - const fleet = [shipsOf(kinu), equipsOf(kinu)] as const + it('floors main and escort fleets separately, including after eligibility filtering', () => { + const main = shipsOf([[2, 1, [68]], [6, 2, []]]) + main[1].eligible = false + const fleets = [main, shipsOf([[2, 3, []]])] + expect(calculateTransport(fleets, 'tank')).toEqual({ + planned: 15, deliverable: 12, hasTransportCargo: true, + }) + expect(calculateTransport([fleets.flat()], 'tank').planned).toBe(16) + }) - // 鬼怒改二 2 + 8 bonus + 8 daihatsu, destroyer 5 + 8 - expect(getTransportPoint(...fleet)).toEqual({ total: 31, actual: 31 }) - // tank: 1.5 + 8 (unscaled) + 6, 3.75 + 6 -> 25.25 - expect(getTransportPoint(...fleet, [], 'tank')).toEqual({ total: 25, actual: 25 }) + it('keeps the Kinu bonus unscaled', () => { + const ships = shipsOf([[3, 487, [68]], [2, 1, [68]]]) + expect(calculateTransport([ships]).planned).toBe(31) + expect(calculateTransport([ships], 'tank').planned).toBe(25) }) - // 2026 summer E-5 transport fleet, checked against the in-game 艦隊戦力分析 panel: - // 205 on the normal transport map, 446 on the tank transport one (E-5-2) - describe('2026 summer E-5 fleet', () => { + it.each(['normal', 'tank'] as const)( + 'counts Kinu once across fleets and independently of eligibility/order (%s)', + (mode) => { + const [ineligible, eligible] = shipsOf([[3, 487, []], [3, 487, []]]) + ineligible.eligible = false + const expected = mode === 'normal' + ? { planned: 12, deliverable: 10, hasTransportCargo: false } + : { planned: 10, deliverable: 9, hasTransportCargo: false } + expect(calculateTransport([[ineligible], [eligible]], mode)).toEqual(expected) + expect(calculateTransport([[eligible], [ineligible]], mode)).toEqual(expected) + const sameFleet = mode === 'normal' ? 12 : 11 + expect(calculateTransport([[ineligible, eligible]], mode).planned).toBe(sameFleet) + expect(calculateTransport([[ineligible, eligible]], mode).deliverable) + .toBe(expected.deliverable) + expect(calculateTransport([[eligible, ineligible]], mode).deliverable) + .toBe(expected.deliverable) + eligible.eligible = false + expect(calculateTransport([[ineligible], [eligible]], mode).deliverable).toBe(0) + }, + ) + + it.each(['normal', 'tank'] as const)( + 'counts Kinu once even when both fleets field a deployable one (%s)', + (mode) => { + const main = shipsOf([[3, 487, []]]) + const escort = shipsOf([[3, 487, []]]) + // Both are eligible: the fleet-level bonus is still granted only once. + const expected = mode === 'normal' ? 12 : 10 + expect(calculateTransport([main, escort], mode)).toEqual({ + planned: expected, deliverable: expected, hasTransportCargo: false, + }) + }, + ) + + // In-game 艦隊戦力分析: normal 205, tank 446. + it('matches the recorded 2026 summer E-5 fleet', () => { const fleet: FleetFixture = [ [22, 1008, [56, 56, 107]], // しまね丸改 [17, 727, [499, 498, 497]], // 第百一号輸送艦 @@ -133,25 +158,18 @@ describe('transport point helpers', () => { [2, 745, [366, 366, 449, 575]], [2, 959, [455, 294, 482]], ] - - it('matches the in-game normal transport value', () => { - expect(getTransportPoint(shipsOf(fleet), equipsOf(fleet))).toEqual({ - total: 205, - actual: 205, - }) + const ships = shipsOf(fleet) + const fleets = [ships.slice(0, 6), ships.slice(6)] + expect(calculateTransport(fleets)).toEqual({ + planned: 205, deliverable: 205, hasTransportCargo: true, }) - - it('matches the in-game tank transport value', () => { - expect(getTransportPoint(shipsOf(fleet), equipsOf(fleet), [], 'tank')).toEqual({ - total: 446, - actual: 446, - }) + expect(calculateTransport(fleets, 'tank')).toEqual({ + planned: 446, deliverable: 446, hasTransportCargo: true, }) }) - // second E-5-2 fleet, checked against `api_landing_hp.api_sub_value` = 468 at S rank. - // 鬼怒改二 sits in the escort fleet: its bonus is what makes this 468 and not 466 - it('matches the tank transport value of a fleet with 鬼怒改二', () => { + // api_landing_hp.api_sub_value = 468 at S rank, Kinu in the escort fleet. + it('matches the recorded tank fleet with Kinu', () => { const fleet: FleetFixture = [ [9, 918, [276, 276, 107, 515]], // Maryland改 [10, 411, [290, 318, 526, 538, 483]], // 扶桑改二 @@ -166,44 +184,22 @@ describe('transport point helpers', () => { [2, 498, [166, 166, 494]], [4, 146, [309, 179, 179, 412]], // 木曾改二 ] - - expect(getTransportPoint(shipsOf(fleet), equipsOf(fleet), [], 'tank')).toEqual({ - total: 468, - actual: 468, + const ships = shipsOf(fleet) + expect(calculateTransport([ships.slice(0, 6), ships.slice(6)], 'tank')).toEqual({ + planned: 468, deliverable: 468, hasTransportCargo: true, }) }) +}) +describe('parseTankTransportMaps', () => { it('reads tank transport maps off chart_additional_info', () => { - expect( - parseTankTransportMaps({ - api_deck_param: [ - { api_seiku_value: 139, api_tp_value: 205, api_atp_value: { 625: 446 } }, - { api_seiku_value: 0, api_tp_value: 0 }, - { api_seiku_value: 0, api_tp_value: 12, api_atp_value: { 624: 0, 625: 30 } }, - ], - }), - ).toEqual([625]) + expect(parseTankTransportMaps({ + api_deck_param: [ + { api_seiku_value: 139, api_tp_value: 205, api_atp_value: { 625: 446 } }, + { api_seiku_value: 0, api_tp_value: 0 }, + { api_seiku_value: 0, api_tp_value: 12, api_atp_value: { 624: 0, 625: 30 } }, + ], + })).toEqual([625]) expect(parseTankTransportMaps({})).toEqual([]) }) - - it('calculates TP from lib-battle ship raw data', () => { - const ships = [ - { - raw: { - api_id: 1, - api_nowhp: 10, - api_maxhp: 20, - api_stype: 2, - api_ship_id: 1, - poi_slot: [slotItem(75)], - poi_slot_ex: slotItem(167), - }, - }, - null, - ] as (Ship | null)[] - - expect(getTransportPointFromFleets([ships])).toEqual({ total: 12, actual: 12 }) - expect(getTransportPointFromFleets([ships], { mode: 'tank' })).toEqual({ total: 20, actual: 20 }) - expect(getTransportPointFromFleets([ships], { escapedShipIds: [1] })).toEqual({ total: 12, actual: 0 }) - }) }) diff --git a/src/utils/transport.ts b/src/utils/transport.ts index 96f4353..540b7a1 100644 --- a/src/utils/transport.ts +++ b/src/utils/transport.ts @@ -1,6 +1,4 @@ import _ from 'lodash' -import type { Ship } from 'poi-lib-battle' -import type { FriendShipRaw } from '../views/ship-view/types' /** * Transport operations come in two flavours since 2026: @@ -52,7 +50,7 @@ const TPByItemType: Record = { } /** - * What the tank table adds on top of `base * TANK_RATIO`, by item id - the trailing + * What the tank table adds on top of `base * 0.75`, by item id - the trailing * comment is the resulting S rank value. Items missing here keep the plain 0.75x base, * which is all a tankless landing craft, a drum or a ration is worth. */ @@ -76,16 +74,25 @@ const TankTPBonusByItem: Record = { 496: 5, // 陸軍歩兵部隊 -> 5 } -const TANK_RATIO = 0.75 +interface TransportRule { + baseMultiplier: { numerator: number; denominator: number } + itemBonusById: Readonly> +} -/** - * 鬼怒改二 delivers one 大発 worth of TP on top of its ship type value, once per fleet. - * The tank table does not scale this one down: it stays 8 there too. - */ -const TPBonusByShip: Record = { - 487: 8, // 鬼怒改二 +const rules: Record = { + normal: { + baseMultiplier: { numerator: 1, denominator: 1 }, + itemBonusById: {}, + }, + tank: { + baseMultiplier: { numerator: 3, denominator: 4 }, + itemBonusById: TankTPBonusByItem, + }, } +const KINU_KAI_NI = 487 +const KINU_BONUS = 8 + const TPByShipType: Record = { 2: 5, 3: 2, @@ -100,123 +107,66 @@ const TPByShipType: Record = { 20: 7, } -type ItemLike = { api_slotitem_id?: number; api_type?: number[] } | null | undefined - -const itemTP = (item: ItemLike, mode: TransportMode): number => { - if (item == null) return 0 - const base = TPByItemType[item.api_type?.[2] ?? -1] ?? 0 - if (mode !== 'tank') return base - return base * TANK_RATIO + (TankTPBonusByItem[item.api_slotitem_id ?? -1] ?? 0) +export interface TransportItem { + itemId: number + category: number } -const shipTypeTP = (stype: number, mode: TransportMode): number => - (TPByShipType[stype] || 0) * (mode === 'tank' ? TANK_RATIO : 1) - -// ship bonuses count once per fleet, however many such ships are in it -const shipBonuses = (shipIds: number[]): number[] => { - const counted = new Set() - return shipIds.map((shipId) => { - const bonus = TPBonusByShip[shipId] || 0 - if (!bonus || counted.has(shipId)) return 0 - counted.add(shipId) - return bonus - }) +export interface TransportShip { + /** Master ship ID, used for ship-specific bonuses. */ + shipId: number + shipType: number + eligible: boolean + items: readonly TransportItem[] } -export interface TPResult { - total: number - actual: number -} - -interface ShipTP { - ignored: boolean - ship: number - bonus: number - equip: number -} - -// tank values are fractional, the fleet total is floored before merge -const collect = (fleets: ShipTP[][]): TPResult => { - const shipTP = ({ ship, bonus, equip }: ShipTP): number => ship + bonus + equip - const ships = fleets.flat() - const equipTP = _.sumBy(ships, 'equip') - const fleetTP = (fleet: ShipTP[]): number => Math.floor(_.sumBy(fleet, shipTP)) - - return { - total: equipTP ? _.sumBy(fleets, fleetTP) : 0, - actual: _.sumBy(fleets, (fleet) => - fleetTP(fleet.filter(({ ignored }) => !ignored)), - ), - } +export interface TransportResult { + planned: number + deliverable: number + /** Cargo presence is separate from the ship's own transport capacity. */ + hasTransportCargo: boolean } -interface TransportShipData { - api_id: number - api_nowhp: number - api_maxhp: number - api_stype: number - api_ship_id: number -} +type TransportFleets = readonly (readonly TransportShip[])[] -type EquipSlotTuple = [ItemLike, { api_type?: number[] }?, ...unknown[]] +const score = (fleets: TransportFleets, rule: TransportRule): number => { + const { numerator, denominator } = rule.baseMultiplier + const fleetPoints = _.sumBy(fleets, (ships) => { + const items = ships.flatMap((ship) => ship.items) + const base = _.sumBy(ships, (ship) => TPByShipType[ship.shipType] ?? 0) + + _.sumBy(items, (item) => TPByItemType[item.category] ?? 0) + const itemBonus = _.sumBy(items, (item) => rule.itemBonusById[item.itemId] ?? 0) -export const getTransportPoint = ( - shipsData: TransportShipData[], - equipsData: (EquipSlotTuple | null | undefined)[][], - escapedShipIds: number[] = [], - mode: TransportMode = 'normal', -): TPResult => { - const bonuses = shipBonuses(_.map(shipsData, 'api_ship_id')) - - return collect([ - _.map(shipsData, (ship, index) => ({ - ignored: escapedShipIds.includes(ship.api_id) || ship.api_nowhp * 4 <= ship.api_maxhp, - ship: shipTypeTP(ship.api_stype, mode), - bonus: bonuses[index], - equip: _.sumBy(equipsData[index] ?? [], (slot) => { - const [item, master] = slot ?? [] - return itemTP(item == null ? item : { ...(master ?? {}), ...item }, mode) - }), - })), - ]) -} + // Preserve per-fleet rounding; do not round individual contributions. + return Math.floor((base * numerator + itemBonus * denominator) / denominator) + }) -type ShipLike = Ship | null | undefined + // Once across main + escort, evaluated on the population being scored. + // This integer bonus stays unscaled in the current tank rule, so adding it + // after per-fleet rounding is equivalent to including it before rounding. + const fleetBonus = fleets.some((ships) => + ships.some((ship) => ship.shipId === KINU_KAI_NI), + ) ? KINU_BONUS : 0 -export interface FleetTransportPointOptions { - escapedShipIds?: number[] - mode?: TransportMode + return fleetPoints + fleetBonus } -export const getTransportPointFromFleets = ( - fleets: ShipLike[][], - { - escapedShipIds = [], - mode = 'normal', - }: FleetTransportPointOptions = {}, -): TPResult => { - const rawFleets = fleets.map((fleet) => - fleet - .filter((ship): ship is Ship => ship != null) - .map((ship) => ship.raw as FriendShipRaw), - ) - const bonuses = shipBonuses( - rawFleets.flatMap((raws) => raws.map((raw) => raw.api_ship_id ?? -1)), - ) - let bonusIndex = 0 - - return collect( - rawFleets.map((raws) => - raws.map((raw) => ({ - ignored: - escapedShipIds.includes(raw.api_id ?? -1) || - (raw.api_nowhp ?? 0) * 4 <= (raw.api_maxhp ?? 0), - ship: shipTypeTP(raw.api_stype, mode), - bonus: bonuses[bonusIndex++], - equip: _.sumBy([...(raw.poi_slot ?? []), raw.poi_slot_ex ?? null], (item) => - itemTP(item, mode), - ), - })), - ), - ) +/** + * S-rank TP from normalized ships. Keep main and escort as separate fleets. + * Eligibility is supplied by the caller; this calculation does not interpret HP. + */ +export const calculateTransport = ( + fleets: TransportFleets, + mode: TransportMode = 'normal', +): TransportResult => { + const rule = rules[mode] + return { + planned: score(fleets, rule), + deliverable: score(fleets.map((ships) => ships.filter((ship) => ship.eligible)), rule), + hasTransportCargo: fleets.some((ships) => ships.some((ship) => + ship.items.some((item) => + (TPByItemType[item.category] ?? 0) > 0 || (rule.itemBonusById[item.itemId] ?? 0) > 0, + ), + )), + } } diff --git a/src/views/battle-view-area.tsx b/src/views/battle-view-area.tsx index 5954c15..68207ab 100644 --- a/src/views/battle-view-area.tsx +++ b/src/views/battle-view-area.tsx @@ -27,8 +27,8 @@ import { friendTitle as buildFriendTitle, isTankTransportMap, transportPoints, + type TPResult, } from './battle-view-model' -import type { TPResult } from '../utils/transport' const FleetsContainer = styled.div<{ horizontalLayout?: boolean }>` display: flex; diff --git a/src/views/battle-view-model.test.ts b/src/views/battle-view-model.test.ts index 73af191..d30ff52 100644 --- a/src/views/battle-view-model.test.ts +++ b/src/views/battle-view-model.test.ts @@ -104,4 +104,36 @@ describe('battle view model helpers', () => { expect(isTankTransportMap(Number(undefined), [625])).toBe(false) expect(isTankTransportMap(Number('625'), [625])).toBe(true) }) + + it('preserves cargo-only display while retaining actual ship capacity', () => { + const ship = { + raw: { + api_id: 1, api_ship_id: 1, api_stype: 2, + api_nowhp: 10, api_maxhp: 20, + }, + } as Ship + expect(transportPoints({ inEvent: true, mainFleet: [ship] })).toEqual({ + normal: { total: 0, actual: 5 }, + tank: { total: 0, actual: 3 }, + }) + }) + + it('retains Kinu bonus when the first Kinu has retreated', () => { + const kinu = (api_id: number) => ({ + raw: { + api_id, api_ship_id: 487, api_stype: 3, + api_nowhp: 10, api_maxhp: 20, + poi_slot: [{ api_slotitem_id: 68, api_type: [0, 0, 24] }], + }, + }) as Ship + expect(transportPoints({ + inEvent: true, + mainFleet: [kinu(1)], + escortFleet: [kinu(2)], + escapedShipIds: [1], + })).toEqual({ + normal: { total: 28, actual: 18 }, + tank: { total: 22, actual: 15 }, + }) + }) }) diff --git a/src/views/battle-view-model.ts b/src/views/battle-view-model.ts index ee44e09..09add1a 100644 --- a/src/views/battle-view-model.ts +++ b/src/views/battle-view-model.ts @@ -2,9 +2,10 @@ import type { Ship } from 'poi-lib-battle' import { combinedFleetType, SortieState, type SortieStateValue } from '../utils/constants' import { - getTransportPointFromFleets, - type TPResult, + calculateTransport, + type TransportResult, } from '../utils/transport' +import { toTransportFleets } from '../utils/transport-adapter' export interface BattleTitleInput { sortieState: SortieStateValue @@ -49,8 +50,19 @@ export const isTankTransportMap = ( tankTransportMaps: readonly number[], ): boolean => Number.isFinite(sortieMapId) && tankTransportMaps.includes(sortieMapId as number) +export interface TPResult { + total: number + actual: number +} + const noTP: TPResult = { total: 0, actual: 0 } +// Preserve the view's cargo-only visibility without discarding ship TP in the calculator. +const displayedTP = ({ planned, deliverable, hasTransportCargo }: TransportResult): TPResult => ({ + total: hasTransportCargo ? planned : 0, + actual: deliverable, +}) + /** * Both flavours of TP, so the caller can show the pair while in port and the one * matching the map once sortied. @@ -67,9 +79,9 @@ export const transportPoints = ({ escapedShipIds?: number[] }): { normal: TPResult; tank: TPResult } => { if (!inEvent) return { normal: noTP, tank: noTP } - const fleets = [mainFleet, escortFleet] + const fleets = toTransportFleets([mainFleet, escortFleet], escapedShipIds) return { - normal: getTransportPointFromFleets(fleets, { escapedShipIds }), - tank: getTransportPointFromFleets(fleets, { escapedShipIds, mode: 'tank' }), + normal: displayedTP(calculateTransport(fleets)), + tank: displayedTP(calculateTransport(fleets, 'tank')), } } From d46343745bc6ef5b2b6e141f79e68e8b3dc8f91c Mon Sep 17 00:00:00 2001 From: KagamiChan <3816900+KagamiChan@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:39:17 +0800 Subject: [PATCH 2/4] refactor: calculate transport directly from Poi data --- docs/transport-formula-notes.md | 169 +++++++++++++++++ src/utils/transport-adapter.test.ts | 100 ----------- src/utils/transport-adapter.ts | 26 --- src/utils/transport.test.ts | 214 ++++++++++++++++------ src/utils/transport.ts | 110 ++++++++---- src/views/battle-view-area.tsx | 7 +- src/views/battle-view-model.test.ts | 269 ++++++++++++++-------------- src/views/battle-view-model.ts | 17 +- 8 files changed, 545 insertions(+), 367 deletions(-) create mode 100644 docs/transport-formula-notes.md delete mode 100644 src/utils/transport-adapter.test.ts delete mode 100644 src/utils/transport-adapter.ts diff --git a/docs/transport-formula-notes.md b/docs/transport-formula-notes.md new file mode 100644 index 0000000..ff85e11 --- /dev/null +++ b/docs/transport-formula-notes.md @@ -0,0 +1,169 @@ +# TP 计算规则 + +本文只记录《舰队 Collection》浏览器版运输作战的 TP 计算规则及其依据,不讨论程序实现。 + +资料核对日期:2026-09-01;补充核对:2026-09-15。 + +本文最初记录于 2026-09-01 的资料恢复核对:当日「艦これ攻略 Wiki」与「ぜかましねっと艦これ!」已读取核对,英文 KanColle Wiki 受 Cloudflare 限制未能重新读取,其内容依此前会话的核对摘要。 + +2026-09-15 补充核对:重新读取了「ぜかましねっと艦これ!」的普通运输与战车运输完整表格,本文所列数值及资料间不一致均与当日读取一致;同日「艦これ攻略 Wiki」受 Cloudflare 限制未能重新读取,英文 KanColle Wiki 本次未重新核对。 + +## 资料来源 + +- [Transport Operation - KanColle Wiki](https://en.kancollewiki.net/Transport_Operation)(英文 Wiki,2026-09-01 与 2026-09-15 均未重新读取) +- [輸送資源量の計算 - 艦これ攻略 Wiki](https://wikiwiki.jp/kancolle/%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88%E6%B5%B7%E5%9F%9F%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88/%E8%BC%B8%E9%80%81%E8%B3%87%E6%BA%90%E9%87%8F%E3%81%AE%E8%A8%88%E7%AE%97_2019%E7%A7%8B)(日文,2026-09-01 已读取;2026-09-15 复核受阻) +- [TP-輸送資源量-を把握しよう - ぜかましねっと艦これ!](https://zekamashi.net/kancolle-kouryaku/yusou-tp/)(日文,2026-09-01 与 2026-09-15 均已读取) + +## 记号 + +对在扬陆点有效的舰船及其装备,定义: + +```text +ShipBase = 舰种基础 TP 之和 +ItemBase = 装备类别基础 TP 之和 +Base = ShipBase + ItemBase +``` + +鬼怒改二另有一次相当于一件大发动艇的 `+8` TP,此项有日文资料支持。就单艘未携带装备的鬼怒改二而言,普通运输中这个追加值与轻巡洋舰本身的 `2` TP 相加,结果为 `10` TP;上述仅描述单艘情形,不代表多艘鬼怒改二会各自叠加。 + +## 普通运输 + +结算前贡献: + +```text +NormalRaw = Base + KinuBonus +``` + +资料注明小数只在整次计算的最后舍去,不能先把每艘舰或每件装备分别向下取整后再相加。 + +普通运输列出的舰种基础值、装备类别基础值与鬼怒改二 `+8` 均为整数,因此 `NormalRaw` 恒为整数。最终结果为: + +```text +S 胜:floor(NormalRaw) +A 胜:floor(NormalRaw × 0.7) +B 胜及以下:0 +``` + +由于 `NormalRaw` 为整数,`floor(NormalRaw × 0.7)` 与 `floor(floor(NormalRaw) × 0.7)` 等价,普通运输不存在中间取整顺序的问题。 + +### 舰种基础 TP + +| 舰种 | S 胜基础值 | +| --- | ---: | +| 驱逐舰 | 5 | +| 轻巡洋舰 | 2 | +| 练习巡洋舰 | 6 | +| 航空巡洋舰 | 4 | +| 航空战舰 | 7 | +| 补给舰 | 15 | +| 水上机母舰 | 9 | +| 扬陆舰 | 12 | +| 潜水空母 | 1 | +| 潜水母舰 | 7 | +| 其他舰种 | 0 | + +### 装备类别基础 TP + +| 装备类别 | S 胜基础值 | +| --- | ---: | +| 大发动艇系、特大发动艇系及其战车型 | 8 | +| 特二式内火艇、特四式内火艇、特四式内火艇改 | 2 | +| ドラム缶(輸送用) | 5 | +| 战斗粮食系 | 1 | +| 其他装备 | 0 | + +装备改修等级不改变 TP。 + +## 战车运输 / Landing Operation + +战车运输规则与地图相关,不能把某次活动的数值视为所有地图永久通用的规则。 + +2026 年夏季活动中已观察到的这套规则,对普通舰种基础值和装备类别基础值统一应用 `0.75`,再加上特定装备的追加值: + +```text +LandingRaw = Base × 0.75 + ItemSpecificBonus +``` + +按同期实测数值,鬼怒改二的 `+8` 在战车运输中不参与 `0.75` 缩放: + +```text +LandingRaw = Base × 0.75 + ItemSpecificBonus + KinuBonus +``` + +证据等级:`+8` 本身有日文资料支持;战车运输中不缩放、以及在整支联合舰队中最多计入一次,仅为实测/既有行为,缺少资料直接记载,待确认。 + +结算前的 `LandingRaw` 保持未取整。取整时机与 A 胜中间值顺序均未确定,故不在此断言 `floor(LandingRaw × 0.7)` 为已确认总公式;资料提到但尚未确认的结算形式如下: + +```text +S 胜:floor(LandingRaw) +A 胜:floor(LandingRaw × 0.7) +B 胜及以下:0 +``` + +### 特定装备追加值 + +下表的“追加值”是在 `普通类别基础值 × 0.75` 之外另行相加的值。“战车运输 S 值”用于交叉检查公式。 + +| 装备 | 普通基础值 | 追加值 | 战车运输 S 值 | +| --- | ---: | ---: | ---: | +| 大発動艇(R35&フランス兵) | 8 | 18 | 24 | +| 特大発動艇+III号戦車J型 | 8 | 17 | 23 | +| 特大発動艇+一式砲戦車 | 8 | 15 | 21 | +| M4A1 DD | 8 | 14 | 20 | +| 特大発動艇+戦車第11連隊 | 8 | 13 | 19 | +| 特大発動艇+チハ改 | 8 | 13 | 19 | +| 特大発動艇+III号戦車(北アフリカ仕様) | 8 | 13 | 19 | +| 特大発動艇+チハ | 8 | 11 | 17 | +| 大発動艇(II号戦車/北アフリカ仕様) | 8 | 10 | 16 | +| 大発動艇(八九式中戦車&陸戦隊) | 8 | 8 | 14 | +| 特大发动艇、大发动艇、武装大发、装甲艇(AB艇) | 8 | 0 | 6 | +| 特四式内火艇改 | 2 | 12 | 13.5 | +| 特二式内火艇 | 2 | 11 | 12.5 | +| 特四式内火艇 | 2 | 10 | 11.5 | +| 陸軍歩兵部隊+チハ改 | 0 | 14 | 14 | +| 九七式中戦車 新砲塔(チハ改) | 0 | 9 | 9 | +| 九七式中戦車(チハ) | 0 | 7 | 7 | +| 陸軍歩兵部隊 | 0 | 5 | 5 | +| ドラム缶(輸送用) | 5 | 0 | 3.75 | +| 战斗粮食系 | 1 | 0 | 0.75 | + +例:大发动艇(R35&フランス兵)的战车运输 S 值为: + +```text +8 × 0.75 + 18 = 24 +``` + +普通舰种基础值同样乘以 `0.75`。例如驱逐舰为 `5 × 0.75 = 3.75`,轻巡洋舰为 `2 × 0.75 = 1.5`。 + +## 哪些舰船计入 TP + +- 在进入扬陆点时已经大破或退避的舰船,以及它们携带的装备,不计入本次运输 TP。 +- 进入扬陆点之后才大破或退避,不影响已经扬陆的 TP。 +- 因此,参与公式的舰队状态应以进入扬陆点时为准。 + +## 资料间的不一致 + +ぜかまし的普通运输表将少数 S 胜为 `8` 的大发系装备写成了 A 胜 `3.5`;同页又说明 A 胜为 S 胜的 `0.7`,而艦これ攻略 Wiki 将这些装备列在共同的 `8 / 5.6` 表格中。两处依据一致,因此这里采用: + +```text +8 × 0.7 = 5.6 +``` + +ぜかまし的战车运输舰种表中,潜水空母与潜水母舰对应的 `0.75`、`5.25` 疑似互换。依据普通基础值 `1`、`7` 及同表统一的 `0.75` 倍率,本文按以下数值理解;该项缺少独立直接核实,属疑似记录问题: + +```text +潜水空母:1 × 0.75 = 0.75 +潜水母舰:7 × 0.75 = 5.25 +``` + +此外,该页在 2026-08-01 更新的段落中写有“2026秋イベントの一例”,与更新时间和当期活动不符,疑似文字误记,缺少独立直接核实,不影响表中的倍率和装备数值。 + +## 尚未由资料确认的部分 + +以下取整顺序只在存在非整数贡献时才有影响,即战车运输(`0.75` 倍率)与联合舰队求和;普通运输各项均为整数,不涉及这些问题。 + +- 联合舰队是主力舰队、随伴舰队分别取整后相加,还是合并原始值后统一取整。 +- 联合舰队 A 胜的 `0.7` 是作用于两队合并的原始值,还是作用于某个已经取整的中间值。 +- 战车运输中鬼怒改二的 `+8` 不受地图倍率影响,目前有游戏内数值支持,但缺少上述资料的明确文字说明。 + +在取得能区分这些情况的直接资料或游戏数据前,不应把其中任一种取整顺序写成已确认规则。 diff --git a/src/utils/transport-adapter.test.ts b/src/utils/transport-adapter.test.ts deleted file mode 100644 index 5985f5a..0000000 --- a/src/utils/transport-adapter.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Ship } from 'poi-lib-battle' -import type { APIGetMemberShip2Response } from 'kcsapi/api_get_member/ship2/response' -import type { APIMstShip, APIMstSlotitem } from 'kcsapi/api_start2/getData/response' - -import { transformToLibBattleClass } from './lib-battle-adapter' -import { calculateTransport } from './transport' -import { toTransportFleets } from './transport-adapter' - -const memberShip = { - api_id: 101, - api_ship_id: 1, - api_nowhp: 10, - api_maxhp: 20, - api_slot_ex: 201, - api_kyouka: [0, 0, 0, 0], - api_karyoku: [0], - api_raisou: [0], - api_taiku: [0], - api_soukou: [0], -} as APIGetMemberShip2Response - -const masterShip = { api_id: 1, api_stype: 2 } as APIMstShip - -const memberItem = (instanceId: number, itemId: number) => ({ - api_id: instanceId, - api_slotitem_id: itemId, - api_level: 0, - api_locked: 0, -}) - -const masterItem = (itemId: number, category: number) => ({ - api_id: itemId, - api_name: String(itemId), - api_type: [0, 0, category, 0, 0], -}) as APIMstSlotitem - -describe('toTransportFleets', () => { - it('preserves member/master equipment data and counts expansion-slot cargo once', () => { - const fleets = transformToLibBattleClass( - [[[memberShip, masterShip], null], []], - [[[ - [memberItem(200, 576), masterItem(576, 24), undefined], - [memberItem(201, 167), masterItem(167, 46), undefined], - null, - ]], []], - ) - - const normalized = toTransportFleets(fleets) - expect(normalized).toEqual([[ - { - shipId: 1, - shipType: 2, - eligible: true, - items: [ - { itemId: 576, category: 24 }, - { itemId: 167, category: 46 }, - ], - }, - ], []]) - expect(calculateTransport(normalized)).toEqual({ - planned: 15, deliverable: 15, hasTransportCargo: true, - }) - // Destroyer 3.75 + R35 24 + Ka-Mi 12.5 = 40.25. - expect(calculateTransport(normalized, 'tank')).toEqual({ - planned: 40, deliverable: 40, hasTransportCargo: true, - }) - expect(toTransportFleets(fleets, [101])[0][0].eligible).toBe(false) - // Retreat IDs are member IDs, not master ship IDs. - expect(toTransportFleets(fleets, [1])[0][0].eligible).toBe(true) - }) - - it.each([[4, false], [5, false], [6, true]])( - 'preserves the HP eligibility threshold at %i / 20 HP', - (api_nowhp, eligible) => { - const fleets = transformToLibBattleClass( - [[[{ ...memberShip, api_nowhp }, masterShip]]], - [], - ) - expect(toTransportFleets(fleets)[0][0].eligible).toBe(eligible) - }, - ) - - it('tolerates empty fleets, empty slots and incomplete raw data', () => { - const ship = { - raw: { api_stype: 2, poi_slot: [null, {}] }, - } as Ship - expect(toTransportFleets([[null, undefined], [ship]])).toEqual([ - [], - [{ - shipId: -1, - shipType: 2, - eligible: false, - items: [{ itemId: -1, category: -1 }], - }], - ]) - expect(toTransportFleets([[{ raw: { api_stype: 2 } } as Ship]])[0][0].items) - .toEqual([]) - }) -}) diff --git a/src/utils/transport-adapter.ts b/src/utils/transport-adapter.ts deleted file mode 100644 index 77f448c..0000000 --- a/src/utils/transport-adapter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Ship } from 'poi-lib-battle' -import type { FriendShipRaw } from '../views/ship-view/types' -import type { TransportShip } from './transport' - -/** Equipment master fields have already been merged by lib-battle-adapter. */ -export const toTransportFleets = ( - fleets: readonly (readonly (Ship | null | undefined)[])[], - escapedShipIds: readonly number[] = [], -): TransportShip[][] => fleets.map((fleet) => fleet - .filter((ship): ship is Ship => ship != null) - .map((ship) => { - const raw = ship.raw as FriendShipRaw - return { - shipId: raw.api_ship_id ?? -1, - shipType: raw.api_stype, - eligible: !escapedShipIds.includes(raw.api_id ?? -1) && - (raw.api_nowhp ?? 0) * 4 > (raw.api_maxhp ?? 0), - items: [...(raw.poi_slot ?? []), raw.poi_slot_ex] - .filter((item) => item != null) - .map((item) => ({ - itemId: item.api_slotitem_id ?? -1, - category: item.api_type?.[2] ?? -1, - })), - } - }), -) diff --git a/src/utils/transport.test.ts b/src/utils/transport.test.ts index 963ef95..0832b9a 100644 --- a/src/utils/transport.test.ts +++ b/src/utils/transport.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from 'vitest' +import type { APIGetMemberShip2Response } from 'kcsapi/api_get_member/ship2/response' +import type { APIGetMemberSlotItemResponse } from 'kcsapi/api_get_member/slot_item/response' +import type { APIMstShip, APIMstSlotitem } from 'kcsapi/api_start2/getData/response' +import type { ProphetEquipEntry, ProphetFleetEntry } from '../types' import { calculateTransport, parseTankTransportMaps, - type TransportShip, } from './transport' // Categories for the two recorded fleet fixtures below. @@ -20,47 +23,142 @@ const category = (itemId: number): number => { return itemId === 75 ? 30 : 1 } -// [ship type, master ship ID, equipment master IDs] -type FleetFixture = [number, number, number[]][] +const memberShip = (api_id: number, api_ship_id: number, nowhp = 10, maxhp = 10) => + ({ api_id, api_ship_id, api_nowhp: nowhp, api_maxhp: maxhp }) as APIGetMemberShip2Response -const shipsOf = (fleet: FleetFixture): TransportShip[] => - fleet.map(([shipType, shipId, items]) => ({ - shipType, - shipId, - eligible: true, - items: items.map((itemId) => ({ itemId, category: category(itemId) })), - })) +const masterShip = (api_stype: number) => ({ api_stype }) as APIMstShip + +const memberItem = (api_slotitem_id: number) => + ({ api_slotitem_id }) as APIGetMemberSlotItemResponse + +const masterItem = (api_type: number) => + ({ api_type: [0, 0, api_type, 0, 0] }) as APIMstSlotitem + +// The calculator consumes the Poi selector shape directly: +// [member, master] ships and [member, master, onslot] slots. +const slot = (itemId: number, itemType = category(itemId)): ProphetEquipEntry => + [memberItem(itemId), masterItem(itemType), undefined] + +// Empty slots are undefined at runtime, which is the only place undefined appears. +type EquipSlots = (ProphetEquipEntry | undefined)[][][] + +// [ship type, master ship ID, equipment master IDs, now HP?, max HP?] +type ShipFixture = [number, number, number[], number?, number?] +type FleetFixture = ShipFixture[] + +const fleetsOf = (fixture: FleetFixture[]): { + fleets: ProphetFleetEntry[][] + equips: ProphetEquipEntry[][][] +} => ({ + fleets: fixture.map((fleet, fleetIndex) => fleet.map( + ([api_stype, api_ship_id, , nowhp = 10, maxhp = 10], shipIndex) => [ + memberShip(fleetIndex * 6 + shipIndex + 1, api_ship_id, nowhp, maxhp), + masterShip(api_stype), + ] as ProphetFleetEntry, + )), + equips: fixture.map((fleet) => fleet.map( + ([, , itemIds]) => itemIds.map((itemId) => slot(itemId)), + )), +}) describe('calculateTransport', () => { it('scores all ships for planned TP and eligible ships for deliverable TP', () => { - const ships = shipsOf([[2, 1, [75]], [3, 487, [999]]]) - ships[1].eligible = false - expect(calculateTransport([ships])).toEqual({ + const allEligible = fleetsOf([[[2, 1, [75]], [3, 487, [999]]]]) + expect(calculateTransport(allEligible.fleets, allEligible.equips)).toEqual({ + planned: 20, deliverable: 20, hasTransportCargo: true, + }) + const kinuIneligible = fleetsOf([[[2, 1, [75]], [3, 487, [999], 0]]]) + expect(calculateTransport(kinuIneligible.fleets, kinuIneligible.equips)).toEqual({ planned: 20, deliverable: 10, hasTransportCargo: true, }) - ships[0].eligible = false - expect(calculateTransport([ships]).deliverable).toBe(0) + const noneEligible = fleetsOf([[[2, 1, [75], 0], [3, 487, [999], 0]]]) + expect(calculateTransport(noneEligible.fleets, noneEligible.equips).deliverable).toBe(0) }) it('keeps ship capacity even when there is no transport cargo', () => { - expect(calculateTransport([shipsOf([[2, 1, []]])])).toEqual({ + const { fleets, equips } = fleetsOf([[[2, 1, []]]]) + expect(calculateTransport(fleets, equips)).toEqual({ planned: 5, deliverable: 5, hasTransportCargo: false, }) - expect(calculateTransport([])).toEqual({ + expect(calculateTransport([], [])).toEqual({ planned: 0, deliverable: 0, hasTransportCargo: false, }) - expect(calculateTransport([[]], 'tank')).toEqual({ + expect(calculateTransport([[]], [[]], { mode: 'tank' })).toEqual({ planned: 0, deliverable: 0, hasTransportCargo: false, }) }) + it('ignores equipment left in a slot without a ship', () => { + const fleets: ProphetFleetEntry[][] = [[[memberShip(1, 2), masterShip(2)], undefined]] + const equips: ProphetEquipEntry[][][] = [[[], [slot(68, 24)]]] + expect(calculateTransport(fleets, equips)).toEqual({ + planned: 5, deliverable: 5, hasTransportCargo: false, + }) + }) + + it.each([[5, false], [6, true]])( + 'uses the raw member HP threshold at %i / 20 HP', + (nowhp, eligible) => { + const { fleets, equips } = fleetsOf([[[2, 1, [], nowhp, 20]]]) + expect(calculateTransport(fleets, equips).deliverable).toBe(eligible ? 5 : 0) + }, + ) + + it('treats retreated member IDs as ineligible but not master ship IDs', () => { + const fleets: ProphetFleetEntry[][] = [[[memberShip(101, 1), masterShip(2)]]] + const equips: EquipSlots = [[[slot(68, 24)]]] + expect(calculateTransport(fleets, equips, { escapedShipIds: [101] }).deliverable).toBe(0) + expect(calculateTransport(fleets, equips, { escapedShipIds: [1] }).deliverable).toBe(13) + }) + + it('keeps ship and equipment positions aligned when a slot has no ship', () => { + const fleets: ProphetFleetEntry[][] = [[ + undefined, + [memberShip(2, 3), masterShip(2)] as ProphetFleetEntry, + ]] + const withPhantomCargo: EquipSlots = [[[slot(576, 24)], [slot(68, 24)]]] + expect(calculateTransport(fleets, withPhantomCargo)).toEqual({ + planned: 13, deliverable: 13, hasTransportCargo: true, + }) + const phantomOnly: EquipSlots = [[[slot(576, 24)], []]] + expect(calculateTransport(fleets, phantomOnly)).toEqual({ + planned: 5, deliverable: 5, hasTransportCargo: false, + }) + }) + + it('reads member equipment against master categories and counts the expansion slot once', () => { + const fleets: ProphetFleetEntry[][] = [[[memberShip(114, 1), masterShip(2)]]] + const equips: EquipSlots = [[[ + slot(576, 24), + undefined, + slot(167, 46), + ]]] + expect(calculateTransport(fleets, equips)).toEqual({ + planned: 15, deliverable: 15, hasTransportCargo: true, + }) + // Destroyer 3.75 + R35 24 + Ka-Mi 12.5 = 40.25. + expect(calculateTransport(fleets, equips, { mode: 'tank' })).toEqual({ + planned: 40, deliverable: 40, hasTransportCargo: true, + }) + }) + + it('keeps counting equipment when the ship master is missing', () => { + const fleets: ProphetFleetEntry[][] = [[ + [memberShip(1, 487), undefined] as unknown as ProphetFleetEntry, + ]] + const equips: EquipSlots = [[[slot(68, 24)]]] + expect(calculateTransport(fleets, equips)).toEqual({ + planned: 16, deliverable: 16, hasTransportCargo: true, + }) + }) + it.each([ [2, 5], [3, 2], [21, 6], [6, 4], [10, 7], [16, 9], [14, 1], [17, 12], [15, 15], [22, 15], [20, 7], [4, 0], [999, 0], ])('scores ship type %i with base %i', (shipType, base) => { - const fleets = [shipsOf([[shipType, 1, []]])] - expect(calculateTransport(fleets).planned).toBe(base) - expect(calculateTransport(fleets, 'tank').planned).toBe(Math.floor(base * 0.75)) + const { fleets, equips } = fleetsOf([[[shipType, 1, []]]]) + expect(calculateTransport(fleets, equips).planned).toBe(base) + expect(calculateTransport(fleets, equips, { mode: 'tank' }).planned).toBe(Math.floor(base * 0.75)) }) it.each([ @@ -71,72 +169,75 @@ describe('calculateTransport', () => { [526, 46, 2, 13.5], [167, 46, 2, 12.5], [525, 46, 2, 11.5], [499, 52, 0, 14], [498, 52, 0, 9], [497, 52, 0, 7], [496, 52, 0, 5], [75, 30, 5, 3.75], [145, 43, 1, 0.75], [9999, 1, 0, 0], - ])('scores equipment %i in category %i', (itemId, category, normal, tank) => { + ])('scores equipment %i in category %i', (itemId, itemCategory, normal, tank) => { // Four copies expose fractional values without per-item rounding. - const ships: TransportShip[] = [{ - shipType: 1, shipId: 1, eligible: true, - items: Array.from({ length: 4 }, () => ({ itemId, category })), - }] - expect(calculateTransport([ships])).toEqual({ + const items = Array.from({ length: 4 }, () => slot(itemId, itemCategory)) + const fleets: ProphetFleetEntry[][] = [[[memberShip(1, 1), masterShip(1)]]] + const equips: ProphetEquipEntry[][][] = [[items]] + expect(calculateTransport(fleets, equips)).toEqual({ planned: normal * 4, deliverable: normal * 4, hasTransportCargo: normal > 0, }) - expect(calculateTransport([ships], 'tank')).toEqual({ + expect(calculateTransport(fleets, equips, { mode: 'tank' })).toEqual({ planned: tank * 4, deliverable: tank * 4, hasTransportCargo: tank > 0, }) }) it('combines fractional ship and equipment contributions before rounding', () => { - const fleets = [shipsOf([[2, 1, [75, 167]]])] - expect(calculateTransport(fleets).planned).toBe(12) + const { fleets, equips } = fleetsOf([[[2, 1, [75, 167]]]]) + expect(calculateTransport(fleets, equips).planned).toBe(12) // Destroyer 3.75 + drum 3.75 + Ka-Mi 12.5. - expect(calculateTransport(fleets, 'tank').planned).toBe(20) + expect(calculateTransport(fleets, equips, { mode: 'tank' }).planned).toBe(20) }) it('floors main and escort fleets separately, including after eligibility filtering', () => { - const main = shipsOf([[2, 1, [68]], [6, 2, []]]) - main[1].eligible = false - const fleets = [main, shipsOf([[2, 3, []]])] - expect(calculateTransport(fleets, 'tank')).toEqual({ + const { fleets, equips } = fleetsOf([ + [[2, 1, [68]], [6, 2, [], 0]], + [[2, 3, []]], + ]) + expect(calculateTransport(fleets, equips, { mode: 'tank' })).toEqual({ planned: 15, deliverable: 12, hasTransportCargo: true, }) - expect(calculateTransport([fleets.flat()], 'tank').planned).toBe(16) + expect(calculateTransport([fleets.flat()], [equips.flat()], { mode: 'tank' }).planned).toBe(16) }) it('keeps the Kinu bonus unscaled', () => { - const ships = shipsOf([[3, 487, [68]], [2, 1, [68]]]) - expect(calculateTransport([ships]).planned).toBe(31) - expect(calculateTransport([ships], 'tank').planned).toBe(25) + const { fleets, equips } = fleetsOf([[[3, 487, [68]], [2, 1, [68]]]]) + expect(calculateTransport(fleets, equips).planned).toBe(31) + expect(calculateTransport(fleets, equips, { mode: 'tank' }).planned).toBe(25) }) it.each(['normal', 'tank'] as const)( 'counts Kinu once across fleets and independently of eligibility/order (%s)', (mode) => { - const [ineligible, eligible] = shipsOf([[3, 487, []], [3, 487, []]]) - ineligible.eligible = false const expected = mode === 'normal' ? { planned: 12, deliverable: 10, hasTransportCargo: false } : { planned: 10, deliverable: 9, hasTransportCargo: false } - expect(calculateTransport([[ineligible], [eligible]], mode)).toEqual(expected) - expect(calculateTransport([[eligible], [ineligible]], mode)).toEqual(expected) + const split = fleetsOf([[[3, 487, [], 0]], [[3, 487, []]]]) + const reversed = fleetsOf([[[3, 487, []]], [[3, 487, [], 0]]]) + expect(calculateTransport(split.fleets, split.equips, { mode })).toEqual(expected) + expect(calculateTransport(reversed.fleets, reversed.equips, { mode })).toEqual(expected) + + const together = fleetsOf([[[3, 487, [], 0], [3, 487, []]]]) + const togetherReversed = fleetsOf([[[3, 487, []], [3, 487, [], 0]]]) const sameFleet = mode === 'normal' ? 12 : 11 - expect(calculateTransport([[ineligible, eligible]], mode).planned).toBe(sameFleet) - expect(calculateTransport([[ineligible, eligible]], mode).deliverable) + expect(calculateTransport(together.fleets, together.equips, { mode }).planned).toBe(sameFleet) + expect(calculateTransport(together.fleets, together.equips, { mode }).deliverable) .toBe(expected.deliverable) - expect(calculateTransport([[eligible, ineligible]], mode).deliverable) + expect(calculateTransport(togetherReversed.fleets, togetherReversed.equips, { mode }).deliverable) .toBe(expected.deliverable) - eligible.eligible = false - expect(calculateTransport([[ineligible], [eligible]], mode).deliverable).toBe(0) + + const noneEligible = fleetsOf([[[3, 487, [], 0], [3, 487, [], 0]]]) + expect(calculateTransport(noneEligible.fleets, noneEligible.equips, { mode }).deliverable).toBe(0) }, ) it.each(['normal', 'tank'] as const)( 'counts Kinu once even when both fleets field a deployable one (%s)', (mode) => { - const main = shipsOf([[3, 487, []]]) - const escort = shipsOf([[3, 487, []]]) + const { fleets, equips } = fleetsOf([[[3, 487, []]], [[3, 487, []]]]) // Both are eligible: the fleet-level bonus is still granted only once. const expected = mode === 'normal' ? 12 : 10 - expect(calculateTransport([main, escort], mode)).toEqual({ + expect(calculateTransport(fleets, equips, { mode })).toEqual({ planned: expected, deliverable: expected, hasTransportCargo: false, }) }, @@ -158,12 +259,11 @@ describe('calculateTransport', () => { [2, 745, [366, 366, 449, 575]], [2, 959, [455, 294, 482]], ] - const ships = shipsOf(fleet) - const fleets = [ships.slice(0, 6), ships.slice(6)] - expect(calculateTransport(fleets)).toEqual({ + const { fleets, equips } = fleetsOf([fleet.slice(0, 6), fleet.slice(6)]) + expect(calculateTransport(fleets, equips)).toEqual({ planned: 205, deliverable: 205, hasTransportCargo: true, }) - expect(calculateTransport(fleets, 'tank')).toEqual({ + expect(calculateTransport(fleets, equips, { mode: 'tank' })).toEqual({ planned: 446, deliverable: 446, hasTransportCargo: true, }) }) @@ -184,8 +284,8 @@ describe('calculateTransport', () => { [2, 498, [166, 166, 494]], [4, 146, [309, 179, 179, 412]], // 木曾改二 ] - const ships = shipsOf(fleet) - expect(calculateTransport([ships.slice(0, 6), ships.slice(6)], 'tank')).toEqual({ + const { fleets, equips } = fleetsOf([fleet.slice(0, 6), fleet.slice(6)]) + expect(calculateTransport(fleets, equips, { mode: 'tank' })).toEqual({ planned: 468, deliverable: 468, hasTransportCargo: true, }) }) diff --git a/src/utils/transport.ts b/src/utils/transport.ts index 540b7a1..435938e 100644 --- a/src/utils/transport.ts +++ b/src/utils/transport.ts @@ -1,4 +1,7 @@ import _ from 'lodash' +import type { APIGetMemberShip2Response } from 'kcsapi/api_get_member/ship2/response' + +import type { ProphetEquipEntry, ProphetFleetEntry } from '../types' /** * Transport operations come in two flavours since 2026: @@ -107,19 +110,6 @@ const TPByShipType: Record = { 20: 7, } -export interface TransportItem { - itemId: number - category: number -} - -export interface TransportShip { - /** Master ship ID, used for ship-specific bonuses. */ - shipId: number - shipType: number - eligible: boolean - items: readonly TransportItem[] -} - export interface TransportResult { planned: number deliverable: number @@ -127,45 +117,97 @@ export interface TransportResult { hasTransportCargo: boolean } -type TransportFleets = readonly (readonly TransportShip[])[] +export interface TransportOptions { + mode?: TransportMode + /** Member ship IDs that have retreated and can no longer deliver. */ + escapedShipIds?: readonly number[] +} -const score = (fleets: TransportFleets, rule: TransportRule): number => { +type TransportFleets = readonly (readonly ProphetFleetEntry[])[] +type TransportEquips = readonly (readonly (readonly (ProphetEquipEntry | undefined)[])[])[] + +type ShipFilter = (member: APIGetMemberShip2Response) => boolean + +// The member HP the selector copies straight from Poi; do not substitute a +// simulated or post-battle value here. +const isEligible = ( + member: APIGetMemberShip2Response, + escapedShipIds: readonly number[], +): boolean => + !escapedShipIds.includes(member.api_id) && + (member.api_nowhp ?? 0) * 4 > (member.api_maxhp ?? 0) + +const fleetScore = ( + fleet: readonly ProphetFleetEntry[], + shipEquips: TransportEquips[number], + rule: TransportRule, + include: ShipFilter, +): number => { const { numerator, denominator } = rule.baseMultiplier - const fleetPoints = _.sumBy(fleets, (ships) => { - const items = ships.flatMap((ship) => ship.items) - const base = _.sumBy(ships, (ship) => TPByShipType[ship.shipType] ?? 0) + - _.sumBy(items, (item) => TPByItemType[item.category] ?? 0) - const itemBonus = _.sumBy(items, (item) => rule.itemBonusById[item.itemId] ?? 0) - - // Preserve per-fleet rounding; do not round individual contributions. - return Math.floor((base * numerator + itemBonus * denominator) / denominator) + let base = 0 + let itemBonus = 0 + + // Pair and slot positions line up with the selector output; empty slots are + // undefined and a missing ship master is possible while the plugin boots up. + fleet.forEach((pair, shipIndex) => { + const [member, master] = pair ?? [] + if (!member || !include(member)) return + base += TPByShipType[master?.api_stype ?? -1] ?? 0 + ;(shipEquips[shipIndex] ?? []).forEach((slot) => { + if (!slot) return + base += TPByItemType[slot[1]?.api_type?.[2] ?? -1] ?? 0 + itemBonus += rule.itemBonusById[slot[0]?.api_slotitem_id ?? -1] ?? 0 + }) }) + // Preserve per-fleet rounding; do not round individual contributions. + return Math.floor((base * numerator + itemBonus * denominator) / denominator) +} + +const score = ( + fleets: TransportFleets, + equips: TransportEquips, + rule: TransportRule, + include: ShipFilter, +): number => { + const fleetPoints = _.sum(fleets.map((fleet, fleetIndex) => + fleetScore(fleet, equips[fleetIndex] ?? [], rule, include), + )) + // Once across main + escort, evaluated on the population being scored. // This integer bonus stays unscaled in the current tank rule, so adding it // after per-fleet rounding is equivalent to including it before rounding. - const fleetBonus = fleets.some((ships) => - ships.some((ship) => ship.shipId === KINU_KAI_NI), - ) ? KINU_BONUS : 0 + const fleetBonus = fleets.some((fleet) => fleet.some( + (pair) => pair != null && include(pair[0]) && pair[0].api_ship_id === KINU_KAI_NI, + )) ? KINU_BONUS : 0 return fleetPoints + fleetBonus } /** - * S-rank TP from normalized ships. Keep main and escort as separate fleets. - * Eligibility is supplied by the caller; this calculation does not interpret HP. + * S-rank TP straight from the Poi fleet selector pairs. Keep main and escort as + * separate fleets; eligibility comes from the member HP and the retreated ship IDs. */ export const calculateTransport = ( fleets: TransportFleets, - mode: TransportMode = 'normal', + equips: TransportEquips, + { mode = 'normal', escapedShipIds = [] }: TransportOptions = {}, ): TransportResult => { const rule = rules[mode] return { - planned: score(fleets, rule), - deliverable: score(fleets.map((ships) => ships.filter((ship) => ship.eligible)), rule), - hasTransportCargo: fleets.some((ships) => ships.some((ship) => - ship.items.some((item) => - (TPByItemType[item.category] ?? 0) > 0 || (rule.itemBonusById[item.itemId] ?? 0) > 0, + planned: score(fleets, equips, rule, () => true), + deliverable: score( + fleets, + equips, + rule, + (member) => isEligible(member, escapedShipIds), + ), + hasTransportCargo: fleets.some((fleet, fleetIndex) => fleet.some((pair, shipIndex) => + pair?.[0] != null && + (equips[fleetIndex]?.[shipIndex] ?? []).some((slot) => + slot != null && + ((TPByItemType[slot[1]?.api_type?.[2] ?? -1] ?? 0) > 0 || + (rule.itemBonusById[slot[0]?.api_slotitem_id ?? -1] ?? 0) > 0), ), )), } diff --git a/src/views/battle-view-area.tsx b/src/views/battle-view-area.tsx index 68207ab..29b6c12 100644 --- a/src/views/battle-view-area.tsx +++ b/src/views/battle-view-area.tsx @@ -19,6 +19,7 @@ import DropInfo from './drop-info' import NextSpotInfo from './next-spot-info' import { PLUGIN_KEY, SortieState } from '../utils' import { tankTransportMapsSelector } from '../redux' +import { selectFleetsEquips } from '../selectors' import type { ProphetBattleResult } from '../types' import type { SortieStateValue } from '../utils/constants' import { @@ -224,7 +225,11 @@ const BattleViewArea: FC = ({ const escapedShipIds = useSelector(escapedShipIdSelector) const inEvent = useSelector(inEventSelector) const tankTransportMaps = useSelector(tankTransportMapsSelector) - const TP = transportPoints({ inEvent, mainFleet, escortFleet, escapedShipIds }) + // TP comes from the Poi fleet/equipment pairs, not the lib-battle view models. + // selectFleetsEquips rebuilds its nested arrays, so compare them deeply to + // keep the selector reference stable between identical states. + const { fleets, equips } = useSelector(selectFleetsEquips, _.isEqual) + const TP = transportPoints({ inEvent, fleets, equips, escapedShipIds }) // in port the map is not decided yet, so both tables are shown side by side; // once sortied only the one the map actually uses is relevant const shownTP: { tp: TPResult; icon: string; label: string }[] = diff --git a/src/views/battle-view-model.test.ts b/src/views/battle-view-model.test.ts index d30ff52..8720a6d 100644 --- a/src/views/battle-view-model.test.ts +++ b/src/views/battle-view-model.test.ts @@ -1,139 +1,130 @@ -import { describe, expect, it } from 'vitest' -import type { Ship } from 'poi-lib-battle' - -import { SortieState } from '../utils/constants' -import { - battleSpotKey, - enemyTitle, - friendTitle, - isTankTransportMap, - transportPoints, -} from './battle-view-model' - -describe('battle view model helpers', () => { - it('builds spot keys for practice and sortie nodes', () => { - expect(battleSpotKey(SortieState.Practice, 11, 2)).toBe('practice') - expect(battleSpotKey(SortieState.Navigation, 11, 2)).toBe('11-2') - }) - - it('builds enemy titles from settings and stored history', () => { - expect(enemyTitle({ sortieState: SortieState.Practice, showEnemyTitle: true })).toBe('PvP') - expect(enemyTitle({ sortieState: SortieState.Battle, showEnemyTitle: true })).toBe('Enemy Vessel') - expect(enemyTitle({ - sortieState: SortieState.Battle, - showEnemyTitle: true, - storedEnemyTitle: 'Abyssal Fleet', - })).toBe('Abyssal Fleet') - expect(enemyTitle({ - sortieState: SortieState.Battle, - showEnemyTitle: false, - storedEnemyTitle: 'Abyssal Fleet', - })).toBe('Enemy Vessel') - }) - - it('builds friendly titles from base defense, combined fleet, and fleet name', () => { - expect(friendTitle({ showEnemyTitle: true, isBaseDefense: true })).toBe('Land Base') - expect(friendTitle({ showEnemyTitle: false, combinedFlag: 1, fleetName: 'Fleet 1' })).toBe('Sortie Fleet') - expect(friendTitle({ showEnemyTitle: true, combinedFlag: 1, fleetName: 'Fleet 1' })).toBe('Carrier Task Force') - expect(friendTitle({ showEnemyTitle: true, combinedFlag: 0, fleetName: 'Fleet 1' })).toBe('Fleet 1') - }) - - it('hides transport points outside event maps', () => { - expect(transportPoints({ inEvent: false })).toEqual({ - normal: { total: 0, actual: 0 }, - tank: { total: 0, actual: 0 }, - }) - }) - - it('uses master data of equipments for transport calculations', () => { - const ship = { - raw: { - api_id: 114, - api_nowhp: 514, - api_maxhp: 666, - api_stype: 1, - api_ship_id: 325, - poi_slot: [{ api_slotitem_id: 576, api_type: [0, 0, 24, 0, 0] }], - }, - } as Ship - - expect( - transportPoints({ - inEvent: true, - mainFleet: [ship], - }), - ).toEqual({ - normal: { total: 8, actual: 8 }, - tank: { total: 24, actual: 24 }, - }) - }) - - it('floors main and escort fleet transport points separately', () => { - const ship = (apiId: number, apiStype: number, items: number[] = []) => ({ - raw: { - api_id: apiId, - api_nowhp: 10, - api_maxhp: 10, - api_stype: apiStype, - api_ship_id: apiId, - poi_slot: items.map((apiSlotitemId) => ({ - api_slotitem_id: apiSlotitemId, - api_type: [0, 0, 24, 0, 0], - })), - }, - }) as Ship - - expect( - transportPoints({ - inEvent: true, - mainFleet: [ship(1, 2, [68]), ship(3, 6)], - escortFleet: [ship(2, 2)], - escapedShipIds: [3], - }), - ).toEqual({ - normal: { total: 22, actual: 18 }, - tank: { total: 15, actual: 12 }, - }) - }) - - it('knows which maps use tank transport', () => { - expect(isTankTransportMap(625, [625])).toBe(true) - expect(isTankTransportMap(624, [625])).toBe(false) - expect(isTankTransportMap(625, [])).toBe(false) - expect(isTankTransportMap(undefined, [625])).toBe(false) - expect(isTankTransportMap(Number(undefined), [625])).toBe(false) - expect(isTankTransportMap(Number('625'), [625])).toBe(true) - }) - - it('preserves cargo-only display while retaining actual ship capacity', () => { - const ship = { - raw: { - api_id: 1, api_ship_id: 1, api_stype: 2, - api_nowhp: 10, api_maxhp: 20, - }, - } as Ship - expect(transportPoints({ inEvent: true, mainFleet: [ship] })).toEqual({ - normal: { total: 0, actual: 5 }, - tank: { total: 0, actual: 3 }, - }) - }) - - it('retains Kinu bonus when the first Kinu has retreated', () => { - const kinu = (api_id: number) => ({ - raw: { - api_id, api_ship_id: 487, api_stype: 3, - api_nowhp: 10, api_maxhp: 20, - poi_slot: [{ api_slotitem_id: 68, api_type: [0, 0, 24] }], - }, - }) as Ship - expect(transportPoints({ - inEvent: true, - mainFleet: [kinu(1)], - escortFleet: [kinu(2)], - escapedShipIds: [1], - })).toEqual({ - normal: { total: 28, actual: 18 }, - tank: { total: 22, actual: 15 }, - }) - }) -}) +import { describe, expect, it } from 'vitest' +import type { APIGetMemberShip2Response } from 'kcsapi/api_get_member/ship2/response' +import type { APIGetMemberSlotItemResponse } from 'kcsapi/api_get_member/slot_item/response' +import type { APIMstShip, APIMstSlotitem } from 'kcsapi/api_start2/getData/response' + +import type { ProphetEquipEntry, ProphetFleetEntry } from '../types' +import { SortieState } from '../utils/constants' +import { + battleSpotKey, + enemyTitle, + friendTitle, + isTankTransportMap, + transportPoints, +} from './battle-view-model' + +const memberShip = (api_id: number, api_ship_id: number, nowhp = 10, maxhp = 10) => + ({ api_id, api_ship_id, api_nowhp: nowhp, api_maxhp: maxhp }) as APIGetMemberShip2Response + +const masterShip = (api_stype: number) => ({ api_stype }) as APIMstShip + +const memberItem = (api_slotitem_id: number) => + ({ api_slotitem_id }) as APIGetMemberSlotItemResponse + +const masterItem = (api_type: number) => + ({ api_type: [0, 0, api_type, 0, 0] }) as APIMstSlotitem + +const slot = (itemId: number, itemType: number): ProphetEquipEntry => + [memberItem(itemId), masterItem(itemType), undefined] + +describe('battle view model helpers', () => { + it('builds spot keys for practice and sortie nodes', () => { + expect(battleSpotKey(SortieState.Practice, 11, 2)).toBe('practice') + expect(battleSpotKey(SortieState.Navigation, 11, 2)).toBe('11-2') + }) + + it('builds enemy titles from settings and stored history', () => { + expect(enemyTitle({ sortieState: SortieState.Practice, showEnemyTitle: true })).toBe('PvP') + expect(enemyTitle({ sortieState: SortieState.Battle, showEnemyTitle: true })).toBe('Enemy Vessel') + expect(enemyTitle({ + sortieState: SortieState.Battle, + showEnemyTitle: true, + storedEnemyTitle: 'Abyssal Fleet', + })).toBe('Abyssal Fleet') + expect(enemyTitle({ + sortieState: SortieState.Battle, + showEnemyTitle: false, + storedEnemyTitle: 'Abyssal Fleet', + })).toBe('Enemy Vessel') + }) + + it('builds friendly titles from base defense, combined fleet, and fleet name', () => { + expect(friendTitle({ showEnemyTitle: true, isBaseDefense: true })).toBe('Land Base') + expect(friendTitle({ showEnemyTitle: false, combinedFlag: 1, fleetName: 'Fleet 1' })).toBe('Sortie Fleet') + expect(friendTitle({ showEnemyTitle: true, combinedFlag: 1, fleetName: 'Fleet 1' })).toBe('Carrier Task Force') + expect(friendTitle({ showEnemyTitle: true, combinedFlag: 0, fleetName: 'Fleet 1' })).toBe('Fleet 1') + }) + + it('hides transport points outside event maps', () => { + expect(transportPoints({ inEvent: false })).toEqual({ + normal: { total: 0, actual: 0 }, + tank: { total: 0, actual: 0 }, + }) + }) + + it('uses master data of equipments for transport calculations', () => { + const fleets: ProphetFleetEntry[][] = [[ + [memberShip(114, 325, 514, 666), masterShip(1)], + ]] + const equips: ProphetEquipEntry[][][] = [[[slot(576, 24)]]] + + expect(transportPoints({ inEvent: true, fleets, equips })).toEqual({ + normal: { total: 8, actual: 8 }, + tank: { total: 24, actual: 24 }, + }) + }) + + it('floors main and escort fleet transport points separately', () => { + const fleets: ProphetFleetEntry[][] = [ + [ + [memberShip(1, 2), masterShip(2)], + [memberShip(3, 6), masterShip(6)], + ], + [[memberShip(2, 2), masterShip(2)]], + ] + const equips: ProphetEquipEntry[][][] = [ + [[slot(68, 24)], []], + [[]], + ] + + expect( + transportPoints({ inEvent: true, fleets, equips, escapedShipIds: [3] }), + ).toEqual({ + normal: { total: 22, actual: 18 }, + tank: { total: 15, actual: 12 }, + }) + }) + + it('knows which maps use tank transport', () => { + expect(isTankTransportMap(625, [625])).toBe(true) + expect(isTankTransportMap(624, [625])).toBe(false) + expect(isTankTransportMap(625, [])).toBe(false) + expect(isTankTransportMap(undefined, [625])).toBe(false) + expect(isTankTransportMap(Number(undefined), [625])).toBe(false) + expect(isTankTransportMap(Number('625'), [625])).toBe(true) + }) + + it('preserves cargo-only display while retaining actual ship capacity', () => { + const fleets: ProphetFleetEntry[][] = [[[memberShip(1, 1), masterShip(2)]]] + const equips: ProphetEquipEntry[][][] = [[[]]] + expect(transportPoints({ inEvent: true, fleets, equips })).toEqual({ + normal: { total: 0, actual: 5 }, + tank: { total: 0, actual: 3 }, + }) + }) + + it('retains Kinu bonus when the first Kinu has retreated', () => { + const kinu = (api_id: number): ProphetFleetEntry => [memberShip(api_id, 487), masterShip(3)] + const fleets: ProphetFleetEntry[][] = [[kinu(1)], [kinu(2)]] + const equips: ProphetEquipEntry[][][] = [[[slot(68, 24)]], [[slot(68, 24)]]] + expect(transportPoints({ + inEvent: true, + fleets, + equips, + escapedShipIds: [1], + })).toEqual({ + normal: { total: 28, actual: 18 }, + tank: { total: 22, actual: 15 }, + }) + }) +}) diff --git a/src/views/battle-view-model.ts b/src/views/battle-view-model.ts index 09add1a..399e476 100644 --- a/src/views/battle-view-model.ts +++ b/src/views/battle-view-model.ts @@ -1,11 +1,9 @@ -import type { Ship } from 'poi-lib-battle' - import { combinedFleetType, SortieState, type SortieStateValue } from '../utils/constants' import { calculateTransport, type TransportResult, } from '../utils/transport' -import { toTransportFleets } from '../utils/transport-adapter' +import type { ProphetEquipEntry, ProphetFleetEntry } from '../types' export interface BattleTitleInput { sortieState: SortieStateValue @@ -69,19 +67,18 @@ const displayedTP = ({ planned, deliverable, hasTransportCargo }: TransportResul */ export const transportPoints = ({ inEvent, - mainFleet = [], - escortFleet = [], + fleets = [], + equips = [], escapedShipIds = [], }: { inEvent: boolean - mainFleet?: (Ship | null)[] - escortFleet?: (Ship | null)[] + fleets?: readonly (readonly ProphetFleetEntry[])[] + equips?: readonly (readonly (readonly (ProphetEquipEntry | undefined)[])[])[] escapedShipIds?: number[] }): { normal: TPResult; tank: TPResult } => { if (!inEvent) return { normal: noTP, tank: noTP } - const fleets = toTransportFleets([mainFleet, escortFleet], escapedShipIds) return { - normal: displayedTP(calculateTransport(fleets)), - tank: displayedTP(calculateTransport(fleets, 'tank')), + normal: displayedTP(calculateTransport(fleets, equips, { escapedShipIds })), + tank: displayedTP(calculateTransport(fleets, equips, { escapedShipIds, mode: 'tank' })), } } From fe6d7c5424d44305e64bca3479961e8c222519cc Mon Sep 17 00:00:00 2001 From: KagamiChan <3816900+KagamiChan@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:46:41 +0800 Subject: [PATCH 3/4] docs: translate transport formula notes into English --- docs/transport-formula-notes.md | 190 ++++++++++++++++---------------- 1 file changed, 95 insertions(+), 95 deletions(-) diff --git a/docs/transport-formula-notes.md b/docs/transport-formula-notes.md index ff85e11..013193e 100644 --- a/docs/transport-formula-notes.md +++ b/docs/transport-formula-notes.md @@ -1,169 +1,169 @@ -# TP 计算规则 +# Transport Point (TP) Calculation Rules -本文只记录《舰队 Collection》浏览器版运输作战的 TP 计算规则及其依据,不讨论程序实现。 +This document records only the TP calculation rules for transport operations in the browser version of Kantai Collection and the sources behind them; it does not discuss program implementation. -资料核对日期:2026-09-01;补充核对:2026-09-15。 +Source check date: 2026-09-01; supplementary check: 2026-09-15. -本文最初记录于 2026-09-01 的资料恢复核对:当日「艦これ攻略 Wiki」与「ぜかましねっと艦これ!」已读取核对,英文 KanColle Wiki 受 Cloudflare 限制未能重新读取,其内容依此前会话的核对摘要。 +This document was first recorded during the 2026-09-01 source-recovery check. On that day the Japanese "艦これ攻略 Wiki" and "ぜかましねっと艦これ!" were read and checked, while the English KanColle Wiki could not be re-read because of a Cloudflare restriction, so its content relies on a check summary from an earlier session. -2026-09-15 补充核对:重新读取了「ぜかましねっと艦これ!」的普通运输与战车运输完整表格,本文所列数值及资料间不一致均与当日读取一致;同日「艦これ攻略 Wiki」受 Cloudflare 限制未能重新读取,英文 KanColle Wiki 本次未重新核对。 +2026-09-15 supplementary check: the full normal-transport and tank-transport tables on "ぜかましねっと艦これ!" were re-read, and the values and inter-source discrepancies listed here all matched that day's reading. On the same day the Japanese "艦これ攻略 Wiki" could not be re-read because of a Cloudflare restriction, and the English KanColle Wiki was not re-checked this time. -## 资料来源 +## Sources -- [Transport Operation - KanColle Wiki](https://en.kancollewiki.net/Transport_Operation)(英文 Wiki,2026-09-01 与 2026-09-15 均未重新读取) -- [輸送資源量の計算 - 艦これ攻略 Wiki](https://wikiwiki.jp/kancolle/%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88%E6%B5%B7%E5%9F%9F%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88/%E8%BC%B8%E9%80%81%E8%B3%87%E6%BA%90%E9%87%8F%E3%81%AE%E8%A8%88%E7%AE%97_2019%E7%A7%8B)(日文,2026-09-01 已读取;2026-09-15 复核受阻) -- [TP-輸送資源量-を把握しよう - ぜかましねっと艦これ!](https://zekamashi.net/kancolle-kouryaku/yusou-tp/)(日文,2026-09-01 与 2026-09-15 均已读取) +- [Transport Operation - KanColle Wiki](https://en.kancollewiki.net/Transport_Operation) (English wiki; not re-read on either 2026-09-01 or 2026-09-15) +- [輸送資源量の計算 - 艦これ攻略 Wiki](https://wikiwiki.jp/kancolle/%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88%E6%B5%B7%E5%9F%9F%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88/%E8%BC%B8%E9%80%81%E8%B3%87%E6%BA%90%E9%87%8F%E3%81%AE%E8%A8%88%E7%AE%97_2019%E7%A7%8B) (Japanese; read on 2026-09-01; re-check blocked on 2026-09-15) +- [TP-輸送資源量-を把握しよう - ぜかましねっと艦これ!](https://zekamashi.net/kancolle-kouryaku/yusou-tp/) (Japanese; read on both 2026-09-01 and 2026-09-15) -## 记号 +## Notation -对在扬陆点有效的舰船及其装备,定义: +For the ships and equipment valid at the landing point, define: ```text -ShipBase = 舰种基础 TP 之和 -ItemBase = 装备类别基础 TP 之和 +ShipBase = sum of ship-type base TP +ItemBase = sum of equipment-category base TP Base = ShipBase + ItemBase ``` -鬼怒改二另有一次相当于一件大发动艇的 `+8` TP,此项有日文资料支持。就单艘未携带装备的鬼怒改二而言,普通运输中这个追加值与轻巡洋舰本身的 `2` TP 相加,结果为 `10` TP;上述仅描述单艘情形,不代表多艘鬼怒改二会各自叠加。 +Kinu Kai Ni additionally grants a `+8` TP bonus equivalent to one Daihatsu landing craft, which is supported by the Japanese source. For a single unarmed Kinu Kai Ni in normal transport, this bonus is added to the light cruiser's own `2` TP, giving `10` TP; the above describes only the single-ship case and does not establish that multiple Kinu Kai Ni would each stack. -## 普通运输 +## Normal transport -结算前贡献: +Pre-settlement contribution: ```text NormalRaw = Base + KinuBonus ``` -资料注明小数只在整次计算的最后舍去,不能先把每艘舰或每件装备分别向下取整后再相加。 +The source notes that decimals are dropped only at the very end of the whole calculation; you must not floor each ship or each piece of equipment separately and then add them. -普通运输列出的舰种基础值、装备类别基础值与鬼怒改二 `+8` 均为整数,因此 `NormalRaw` 恒为整数。最终结果为: +All ship-type base values, equipment-category base values, and the Kinu Kai Ni `+8` listed for normal transport are integers, so `NormalRaw` is always an integer. The final result is: ```text -S 胜:floor(NormalRaw) -A 胜:floor(NormalRaw × 0.7) -B 胜及以下:0 +S rank: floor(NormalRaw) +A rank: floor(NormalRaw × 0.7) +B or below: 0 ``` -由于 `NormalRaw` 为整数,`floor(NormalRaw × 0.7)` 与 `floor(floor(NormalRaw) × 0.7)` 等价,普通运输不存在中间取整顺序的问题。 +Because `NormalRaw` is an integer, `floor(NormalRaw × 0.7)` and `floor(floor(NormalRaw) × 0.7)` are equivalent, so normal transport has no intermediate-rounding-order issue. -### 舰种基础 TP +### Ship-type base TP -| 舰种 | S 胜基础值 | +| Ship type | S rank base value | | --- | ---: | -| 驱逐舰 | 5 | -| 轻巡洋舰 | 2 | -| 练习巡洋舰 | 6 | -| 航空巡洋舰 | 4 | -| 航空战舰 | 7 | -| 补给舰 | 15 | -| 水上机母舰 | 9 | -| 扬陆舰 | 12 | -| 潜水空母 | 1 | -| 潜水母舰 | 7 | -| 其他舰种 | 0 | - -### 装备类别基础 TP - -| 装备类别 | S 胜基础值 | +| Destroyer | 5 | +| Light cruiser | 2 | +| Training cruiser | 6 | +| Aviation cruiser | 4 | +| Aviation battleship | 7 | +| Fleet oiler | 15 | +| Seaplane tender | 9 | +| Landing ship | 12 | +| Submarine aircraft carrier (SSV) | 1 | +| Submarine tender (AS) | 7 | +| Other ship types | 0 | + +### Equipment-category base TP + +| Equipment category | S rank base value | | --- | ---: | -| 大发动艇系、特大发动艇系及其战车型 | 8 | -| 特二式内火艇、特四式内火艇、特四式内火艇改 | 2 | -| ドラム缶(輸送用) | 5 | -| 战斗粮食系 | 1 | -| 其他装备 | 0 | +| Daihatsu-class and Toku Daihatsu-class landing craft and their tank variants (大発動艇系、特大発動艇系) | 8 | +| Special Type 2 / Type 4 / Type 4 Kai amphibious tanks (特二式内火艇、特四式内火艇、特四式内火艇改) | 2 | +| Drum Can (Transport) (ドラム缶(輸送用)) | 5 | +| Combat rations (戦闘糧食系) | 1 | +| Other equipment | 0 | -装备改修等级不改变 TP。 +Equipment improvement level does not change TP. -## 战车运输 / Landing Operation +## Tank transport / Landing Operation -战车运输规则与地图相关,不能把某次活动的数值视为所有地图永久通用的规则。 +Tank transport rules depend on the map; a value observed in one event must not be treated as a permanent, universal rule for all maps. -2026 年夏季活动中已观察到的这套规则,对普通舰种基础值和装备类别基础值统一应用 `0.75`,再加上特定装备的追加值: +For the rule observed in the 2026 summer event, a `0.75` multiplier is applied uniformly to the normal ship-type base values and equipment-category base values, and the specific-equipment bonus is added on top: ```text LandingRaw = Base × 0.75 + ItemSpecificBonus ``` -按同期实测数值,鬼怒改二的 `+8` 在战车运输中不参与 `0.75` 缩放: +According to measured values from the same period, the Kinu Kai Ni `+8` does not participate in the `0.75` scaling in tank transport: ```text LandingRaw = Base × 0.75 + ItemSpecificBonus + KinuBonus ``` -证据等级:`+8` 本身有日文资料支持;战车运输中不缩放、以及在整支联合舰队中最多计入一次,仅为实测/既有行为,缺少资料直接记载,待确认。 +Evidence level: the `+8` itself is supported by the Japanese source; that it is unscaled in tank transport, and that it is counted at most once across the whole combined fleet, are only measured/existing behavior lacking direct source documentation, and remain unconfirmed. -结算前的 `LandingRaw` 保持未取整。取整时机与 A 胜中间值顺序均未确定,故不在此断言 `floor(LandingRaw × 0.7)` 为已确认总公式;资料提到但尚未确认的结算形式如下: +Before settlement, `LandingRaw` is kept unrounded. Neither the rounding timing nor the A-rank intermediate-value order is determined, so we do not assert `floor(LandingRaw × 0.7)` as a confirmed total formula here; the settlement forms mentioned by the sources but not yet confirmed are: ```text -S 胜:floor(LandingRaw) -A 胜:floor(LandingRaw × 0.7) -B 胜及以下:0 +S rank: floor(LandingRaw) +A rank: floor(LandingRaw × 0.7) +B or below: 0 ``` -### 特定装备追加值 +### Specific equipment bonus -下表的“追加值”是在 `普通类别基础值 × 0.75` 之外另行相加的值。“战车运输 S 值”用于交叉检查公式。 +The "bonus" column below is added on top of `normal category base value × 0.75`. The "tank transport S value" column cross-checks the formula. -| 装备 | 普通基础值 | 追加值 | 战车运输 S 值 | +| Equipment | Normal base value | Bonus | Tank transport S value | | --- | ---: | ---: | ---: | -| 大発動艇(R35&フランス兵) | 8 | 18 | 24 | -| 特大発動艇+III号戦車J型 | 8 | 17 | 23 | -| 特大発動艇+一式砲戦車 | 8 | 15 | 21 | +| Daihatsu Landing Craft (R35 & French Infantry) (大発動艇(R35&フランス兵)) | 8 | 18 | 24 | +| Toku Daihatsu Landing Craft + Type III Tank J (特大発動艇+III号戦車J型) | 8 | 17 | 23 | +| Toku Daihatsu Landing Craft + Type 1 Gun Tank (特大発動艇+一式砲戦車) | 8 | 15 | 21 | | M4A1 DD | 8 | 14 | 20 | -| 特大発動艇+戦車第11連隊 | 8 | 13 | 19 | -| 特大発動艇+チハ改 | 8 | 13 | 19 | -| 特大発動艇+III号戦車(北アフリカ仕様) | 8 | 13 | 19 | -| 特大発動艇+チハ | 8 | 11 | 17 | -| 大発動艇(II号戦車/北アフリカ仕様) | 8 | 10 | 16 | -| 大発動艇(八九式中戦車&陸戦隊) | 8 | 8 | 14 | -| 特大发动艇、大发动艇、武装大发、装甲艇(AB艇) | 8 | 0 | 6 | -| 特四式内火艇改 | 2 | 12 | 13.5 | -| 特二式内火艇 | 2 | 11 | 12.5 | -| 特四式内火艇 | 2 | 10 | 11.5 | -| 陸軍歩兵部隊+チハ改 | 0 | 14 | 14 | -| 九七式中戦車 新砲塔(チハ改) | 0 | 9 | 9 | -| 九七式中戦車(チハ) | 0 | 7 | 7 | -| 陸軍歩兵部隊 | 0 | 5 | 5 | -| ドラム缶(輸送用) | 5 | 0 | 3.75 | -| 战斗粮食系 | 1 | 0 | 0.75 | - -例:大发动艇(R35&フランス兵)的战车运输 S 值为: +| Toku Daihatsu Landing Craft + 11th Tank Regiment (特大発動艇+戦車第11連隊) | 8 | 13 | 19 | +| Toku Daihatsu Landing Craft + Chi-Ha Kai (特大発動艇+チハ改) | 8 | 13 | 19 | +| Toku Daihatsu Landing Craft + Type III Tank (North African Specification) (特大発動艇+III号戦車(北アフリカ仕様)) | 8 | 13 | 19 | +| Toku Daihatsu Landing Craft + Chi-Ha (特大発動艇+チハ) | 8 | 11 | 17 | +| Daihatsu Landing Craft (Type II Tank / North African Specification) (大発動艇(II号戦車/北アフリカ仕様)) | 8 | 10 | 16 | +| Daihatsu Landing Craft (Type 89 Tank & Marines) (大発動艇(八九式中戦車&陸戦隊)) | 8 | 8 | 14 | +| Toku Daihatsu / Daihatsu / Armed Daihatsu / Armored Boat (AB) (特大発動艇、大発動艇、武装大発、装甲艇(AB艇)) | 8 | 0 | 6 | +| Special Type 4 Amphibious Tank Kai (特四式内火艇改) | 2 | 12 | 13.5 | +| Special Type 2 Amphibious Tank (特二式内火艇) | 2 | 11 | 12.5 | +| Special Type 4 Amphibious Tank (特四式内火艇) | 2 | 10 | 11.5 | +| Army Infantry Unit + Chi-Ha Kai (陸軍歩兵部隊+チハ改) | 0 | 14 | 14 | +| Type 97 Medium Tank New Turret (Chi-Ha Kai) (九七式中戦車 新砲塔(チハ改)) | 0 | 9 | 9 | +| Type 97 Medium Tank (Chi-Ha) (九七式中戦車(チハ)) | 0 | 7 | 7 | +| Army Infantry Unit (陸軍歩兵部隊) | 0 | 5 | 5 | +| Drum Can (Transport) (ドラム缶(輸送用)) | 5 | 0 | 3.75 | +| Combat rations (戦闘糧食系) | 1 | 0 | 0.75 | + +Example: the tank transport S value of Daihatsu Landing Craft (R35 & French Infantry) is: ```text 8 × 0.75 + 18 = 24 ``` -普通舰种基础值同样乘以 `0.75`。例如驱逐舰为 `5 × 0.75 = 3.75`,轻巡洋舰为 `2 × 0.75 = 1.5`。 +Normal ship-type base values are also multiplied by `0.75`. For example, a destroyer is `5 × 0.75 = 3.75` and a light cruiser is `2 × 0.75 = 1.5`. -## 哪些舰船计入 TP +## Which ships count toward TP -- 在进入扬陆点时已经大破或退避的舰船,以及它们携带的装备,不计入本次运输 TP。 -- 进入扬陆点之后才大破或退避,不影响已经扬陆的 TP。 -- 因此,参与公式的舰队状态应以进入扬陆点时为准。 +- Ships that were already heavily damaged or had retreated when entering the landing point, and the equipment they carry, do not count toward this transport's TP. +- Heavy damage or retreat after entering the landing point does not affect the TP already landed. +- Therefore, the fleet state used in the formula should be the state when entering the landing point. -## 资料间的不一致 +## Inconsistencies between sources -ぜかまし的普通运输表将少数 S 胜为 `8` 的大发系装备写成了 A 胜 `3.5`;同页又说明 A 胜为 S 胜的 `0.7`,而艦これ攻略 Wiki 将这些装备列在共同的 `8 / 5.6` 表格中。两处依据一致,因此这里采用: +In zekamashi's normal-transport table, a few Daihatsu-class items whose S rank is `8` are written as A rank `3.5`; the same page also states that A rank is `0.7` of S rank, while the 艦これ攻略 Wiki lists these items in a common `8 / 5.6` table. Both bases agree, so we adopt: ```text 8 × 0.7 = 5.6 ``` -ぜかまし的战车运输舰种表中,潜水空母与潜水母舰对应的 `0.75`、`5.25` 疑似互换。依据普通基础值 `1`、`7` 及同表统一的 `0.75` 倍率,本文按以下数值理解;该项缺少独立直接核实,属疑似记录问题: +In zekamashi's tank-transport ship-type table, the `0.75` and `5.25` values for the submarine aircraft carrier (SSV) and submarine tender (AS) appear to be swapped. Based on the normal base values `1` and `7` and the table's uniform `0.75` multiplier, this document reads them as follows; this item lacks independent direct confirmation and is treated as a suspected recording issue: ```text -潜水空母:1 × 0.75 = 0.75 -潜水母舰:7 × 0.75 = 5.25 +Submarine aircraft carrier (SSV): 1 × 0.75 = 0.75 +Submarine tender (AS): 7 × 0.75 = 5.25 ``` -此外,该页在 2026-08-01 更新的段落中写有“2026秋イベントの一例”,与更新时间和当期活动不符,疑似文字误记,缺少独立直接核实,不影响表中的倍率和装备数值。 +In addition, a paragraph updated on 2026-08-01 on that page says "2026秋イベントの一例" (an example from the 2026 autumn event), which does not match the update time or the event then current; this appears to be a textual misstatement, lacks independent direct confirmation, and does not affect the multipliers or equipment values in the table. -## 尚未由资料确认的部分 +## Not yet confirmed by the sources -以下取整顺序只在存在非整数贡献时才有影响,即战车运输(`0.75` 倍率)与联合舰队求和;普通运输各项均为整数,不涉及这些问题。 +The rounding orders below only matter when non-integer contributions exist, i.e. tank transport (the `0.75` multiplier) and combined-fleet sums; in normal transport every term is an integer, so these issues do not arise. -- 联合舰队是主力舰队、随伴舰队分别取整后相加,还是合并原始值后统一取整。 -- 联合舰队 A 胜的 `0.7` 是作用于两队合并的原始值,还是作用于某个已经取整的中间值。 -- 战车运输中鬼怒改二的 `+8` 不受地图倍率影响,目前有游戏内数值支持,但缺少上述资料的明确文字说明。 +- Whether a combined fleet is rounded per fleet (main and escort separately, then summed) or summed as raw values and rounded once. +- Whether the combined-fleet A-rank `0.7` applies to the two fleets' combined raw value or to some already-rounded intermediate value. +- In tank transport, whether the Kinu Kai Ni `+8` is unaffected by the map multiplier; it currently has in-game value support but lacks explicit wording in the sources above. -在取得能区分这些情况的直接资料或游戏数据前,不应把其中任一种取整顺序写成已确认规则。 +Until direct sources or game data that can distinguish these cases are available, none of these rounding orders should be written as a confirmed rule. From 82d0f9c92fe9bfcf0509da8dd4a3c0c0fe6a6305 Mon Sep 17 00:00:00 2001 From: KagamiChan <3816900+KagamiChan@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:53:55 +0800 Subject: [PATCH 4/4] refactor: extract standalone transport point display --- src/views/battle-view-area.tsx | 75 +----------- src/views/battle-view-model.test.ts | 93 --------------- src/views/battle-view-model.ts | 45 -------- src/views/transport-points.test.tsx | 173 ++++++++++++++++++++++++++++ src/views/transport-points.tsx | 120 +++++++++++++++++++ vitest.config.ts | 24 ++++ 6 files changed, 319 insertions(+), 211 deletions(-) create mode 100644 src/views/transport-points.test.tsx create mode 100644 src/views/transport-points.tsx diff --git a/src/views/battle-view-area.tsx b/src/views/battle-view-area.tsx index 29b6c12..0d959bd 100644 --- a/src/views/battle-view-area.tsx +++ b/src/views/battle-view-area.tsx @@ -1,10 +1,8 @@ import FontAwesome from 'react-fontawesome' import React, { FC, useEffect, useState } from 'react' -import { createSelector } from 'reselect' import _ from 'lodash' import { useSelector } from 'react-redux' import { Collapse } from '@blueprintjs/core' -import { Tooltip } from 'views/components/etc/overlay' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import type { Ship } from 'poi-lib-battle' @@ -17,18 +15,14 @@ import SquadView from './squad-view' import BattleInfo from './battle-info' import DropInfo from './drop-info' import NextSpotInfo from './next-spot-info' +import TransportPoints from './transport-points' import { PLUGIN_KEY, SortieState } from '../utils' -import { tankTransportMapsSelector } from '../redux' -import { selectFleetsEquips } from '../selectors' import type { ProphetBattleResult } from '../types' import type { SortieStateValue } from '../utils/constants' import { battleSpotKey, enemyTitle as buildEnemyTitle, friendTitle as buildFriendTitle, - isTankTransportMap, - transportPoints, - type TPResult, } from './battle-view-model' const FleetsContainer = styled.div<{ horizontalLayout?: boolean }>` @@ -131,28 +125,6 @@ const AirRaidHeader = styled.div<{ isOpen?: boolean }>` } ` -const inEventSelector = createSelector( - [(state: PoiRootState) => state.const?.$maps], - (maps = {}) => Object.keys(maps).some((mapId) => +mapId > 100), -) - -const escapedShipIdSelector = createSelector( - [ - (state: PoiRootState) => state.sortie.escapedPos ?? [], - (state: PoiRootState) => state.sortie.combinedFlag ?? 0, - (state: PoiRootState) => state, - ], - (escapedPos: number[], combinedFlag: number, state: PoiRootState) => { - if (combinedFlag > 0) { - const shipIds = _.flatMap([0, 1], (fleetId) => - state.info?.fleets?.[fleetId]?.api_ship ?? [], - ) - return escapedPos.map((pos) => shipIds[pos]) - } - return [] - }, -) - interface BattleViewAreaProps { mainFleet?: (Ship | null)[] escortFleet?: (Ship | null)[] @@ -222,27 +194,6 @@ const BattleViewArea: FC = ({ ) const enemyTitle = buildEnemyTitle({ sortieState, showEnemyTitle, storedEnemyTitle: historyTitle }) - const escapedShipIds = useSelector(escapedShipIdSelector) - const inEvent = useSelector(inEventSelector) - const tankTransportMaps = useSelector(tankTransportMapsSelector) - // TP comes from the Poi fleet/equipment pairs, not the lib-battle view models. - // selectFleetsEquips rebuilds its nested arrays, so compare them deeply to - // keep the selector reference stable between identical states. - const { fleets, equips } = useSelector(selectFleetsEquips, _.isEqual) - const TP = transportPoints({ inEvent, fleets, equips, escapedShipIds }) - // in port the map is not decided yet, so both tables are shown side by side; - // once sortied only the one the map actually uses is relevant - const shownTP: { tp: TPResult; icon: string; label: string }[] = - sortieState === SortieState.InPort - ? [ - { tp: TP.normal, icon: 'database', label: 'Transport Point' }, - { tp: TP.tank, icon: 'truck', label: 'Tank Transport Point' }, - ] - : // the api hands the map id over as a string on some responses - isTankTransportMap(Number(sortieMapId), tankTransportMaps) - ? [{ tp: TP.tank, icon: 'truck', label: 'Tank Transport Point' }] - : [{ tp: TP.normal, icon: 'database', label: 'Transport Point' }] - const fleetName = useSelector((state: PoiRootState) => state.info?.fleets?.[fleetIds[0]]?.api_name ?? 'Sortie Fleet', ) @@ -333,29 +284,7 @@ const BattleViewArea: FC = ({ {`${t(friendTitle)}`} - {!baseDefense && - shownTP.map( - ({ tp, icon, label }) => - tp.total > 0 && ( - - -
{t(label)}
- {`${t('A_rank')}${Math.floor(tp.actual * 0.7)}`} - - } - > - - [ - {tp.total !== tp.actual && {`${tp.actual} / `}} - {tp.total}] - -
-
- ), - )} + {!baseDefense && } {shownAirForce[0] > 0 && ( diff --git a/src/views/battle-view-model.test.ts b/src/views/battle-view-model.test.ts index 8720a6d..643d32d 100644 --- a/src/views/battle-view-model.test.ts +++ b/src/views/battle-view-model.test.ts @@ -1,32 +1,12 @@ import { describe, expect, it } from 'vitest' -import type { APIGetMemberShip2Response } from 'kcsapi/api_get_member/ship2/response' -import type { APIGetMemberSlotItemResponse } from 'kcsapi/api_get_member/slot_item/response' -import type { APIMstShip, APIMstSlotitem } from 'kcsapi/api_start2/getData/response' -import type { ProphetEquipEntry, ProphetFleetEntry } from '../types' import { SortieState } from '../utils/constants' import { battleSpotKey, enemyTitle, friendTitle, - isTankTransportMap, - transportPoints, } from './battle-view-model' -const memberShip = (api_id: number, api_ship_id: number, nowhp = 10, maxhp = 10) => - ({ api_id, api_ship_id, api_nowhp: nowhp, api_maxhp: maxhp }) as APIGetMemberShip2Response - -const masterShip = (api_stype: number) => ({ api_stype }) as APIMstShip - -const memberItem = (api_slotitem_id: number) => - ({ api_slotitem_id }) as APIGetMemberSlotItemResponse - -const masterItem = (api_type: number) => - ({ api_type: [0, 0, api_type, 0, 0] }) as APIMstSlotitem - -const slot = (itemId: number, itemType: number): ProphetEquipEntry => - [memberItem(itemId), masterItem(itemType), undefined] - describe('battle view model helpers', () => { it('builds spot keys for practice and sortie nodes', () => { expect(battleSpotKey(SortieState.Practice, 11, 2)).toBe('practice') @@ -54,77 +34,4 @@ describe('battle view model helpers', () => { expect(friendTitle({ showEnemyTitle: true, combinedFlag: 1, fleetName: 'Fleet 1' })).toBe('Carrier Task Force') expect(friendTitle({ showEnemyTitle: true, combinedFlag: 0, fleetName: 'Fleet 1' })).toBe('Fleet 1') }) - - it('hides transport points outside event maps', () => { - expect(transportPoints({ inEvent: false })).toEqual({ - normal: { total: 0, actual: 0 }, - tank: { total: 0, actual: 0 }, - }) - }) - - it('uses master data of equipments for transport calculations', () => { - const fleets: ProphetFleetEntry[][] = [[ - [memberShip(114, 325, 514, 666), masterShip(1)], - ]] - const equips: ProphetEquipEntry[][][] = [[[slot(576, 24)]]] - - expect(transportPoints({ inEvent: true, fleets, equips })).toEqual({ - normal: { total: 8, actual: 8 }, - tank: { total: 24, actual: 24 }, - }) - }) - - it('floors main and escort fleet transport points separately', () => { - const fleets: ProphetFleetEntry[][] = [ - [ - [memberShip(1, 2), masterShip(2)], - [memberShip(3, 6), masterShip(6)], - ], - [[memberShip(2, 2), masterShip(2)]], - ] - const equips: ProphetEquipEntry[][][] = [ - [[slot(68, 24)], []], - [[]], - ] - - expect( - transportPoints({ inEvent: true, fleets, equips, escapedShipIds: [3] }), - ).toEqual({ - normal: { total: 22, actual: 18 }, - tank: { total: 15, actual: 12 }, - }) - }) - - it('knows which maps use tank transport', () => { - expect(isTankTransportMap(625, [625])).toBe(true) - expect(isTankTransportMap(624, [625])).toBe(false) - expect(isTankTransportMap(625, [])).toBe(false) - expect(isTankTransportMap(undefined, [625])).toBe(false) - expect(isTankTransportMap(Number(undefined), [625])).toBe(false) - expect(isTankTransportMap(Number('625'), [625])).toBe(true) - }) - - it('preserves cargo-only display while retaining actual ship capacity', () => { - const fleets: ProphetFleetEntry[][] = [[[memberShip(1, 1), masterShip(2)]]] - const equips: ProphetEquipEntry[][][] = [[[]]] - expect(transportPoints({ inEvent: true, fleets, equips })).toEqual({ - normal: { total: 0, actual: 5 }, - tank: { total: 0, actual: 3 }, - }) - }) - - it('retains Kinu bonus when the first Kinu has retreated', () => { - const kinu = (api_id: number): ProphetFleetEntry => [memberShip(api_id, 487), masterShip(3)] - const fleets: ProphetFleetEntry[][] = [[kinu(1)], [kinu(2)]] - const equips: ProphetEquipEntry[][][] = [[[slot(68, 24)]], [[slot(68, 24)]]] - expect(transportPoints({ - inEvent: true, - fleets, - equips, - escapedShipIds: [1], - })).toEqual({ - normal: { total: 28, actual: 18 }, - tank: { total: 22, actual: 15 }, - }) - }) }) diff --git a/src/views/battle-view-model.ts b/src/views/battle-view-model.ts index 399e476..ad88b83 100644 --- a/src/views/battle-view-model.ts +++ b/src/views/battle-view-model.ts @@ -1,9 +1,4 @@ import { combinedFleetType, SortieState, type SortieStateValue } from '../utils/constants' -import { - calculateTransport, - type TransportResult, -} from '../utils/transport' -import type { ProphetEquipEntry, ProphetFleetEntry } from '../types' export interface BattleTitleInput { sortieState: SortieStateValue @@ -42,43 +37,3 @@ export const friendTitle = ({ if (!showEnemyTitle) return 'Sortie Fleet' return combinedFlag > 0 ? (combinedFleetType[combinedFlag] ?? 'Combined Fleet') : fleetName } - -export const isTankTransportMap = ( - sortieMapId: number | undefined, - tankTransportMaps: readonly number[], -): boolean => Number.isFinite(sortieMapId) && tankTransportMaps.includes(sortieMapId as number) - -export interface TPResult { - total: number - actual: number -} - -const noTP: TPResult = { total: 0, actual: 0 } - -// Preserve the view's cargo-only visibility without discarding ship TP in the calculator. -const displayedTP = ({ planned, deliverable, hasTransportCargo }: TransportResult): TPResult => ({ - total: hasTransportCargo ? planned : 0, - actual: deliverable, -}) - -/** - * Both flavours of TP, so the caller can show the pair while in port and the one - * matching the map once sortied. - */ -export const transportPoints = ({ - inEvent, - fleets = [], - equips = [], - escapedShipIds = [], -}: { - inEvent: boolean - fleets?: readonly (readonly ProphetFleetEntry[])[] - equips?: readonly (readonly (readonly (ProphetEquipEntry | undefined)[])[])[] - escapedShipIds?: number[] -}): { normal: TPResult; tank: TPResult } => { - if (!inEvent) return { normal: noTP, tank: noTP } - return { - normal: displayedTP(calculateTransport(fleets, equips, { escapedShipIds })), - tank: displayedTP(calculateTransport(fleets, equips, { escapedShipIds, mode: 'tank' })), - } -} diff --git a/src/views/transport-points.test.tsx b/src/views/transport-points.test.tsx new file mode 100644 index 0000000..16f62d2 --- /dev/null +++ b/src/views/transport-points.test.tsx @@ -0,0 +1,173 @@ +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { APIGetMemberShip2Response } from 'kcsapi/api_get_member/ship2/response' +import type { APIGetMemberSlotItemResponse } from 'kcsapi/api_get_member/slot_item/response' +import type { APIMstShip, APIMstSlotitem } from 'kcsapi/api_start2/getData/response' + +import type { ProphetEquipEntry, ProphetFleetEntry } from '../types' +import { SortieState } from '../utils/constants' + +const mocks = vi.hoisted(() => ({ + state: {} as unknown as PoiRootState, + fleets: {} as Record, + equips: {} as Record, +})) + +vi.mock('react-redux', () => ({ + useSelector: (selector: (state: PoiRootState) => unknown) => selector(mocks.state), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock('react-fontawesome', () => ({ + default: ({ name }: { name: string }) => React.createElement('span', { 'data-testid': `fa-${name}` }), +})) + +vi.mock('views/components/etc/overlay', () => ({ + Tooltip: ({ children, content }: { children?: React.ReactNode; content?: React.ReactNode }) => + React.createElement(React.Fragment, null, children, content), +})) + +// Host-only selector factories: the plugin's own selectFleetsEquips runs on top of them. +vi.mock('views/utils/selectors', () => ({ + extensionSelectorFactory: () => (state: PoiRootState) => state.ext, + fleetSelectorFactory: (id: number) => () => ({ + api_ship: new Array((mocks.fleets[id] as unknown[] | undefined)?.length ?? 0).fill(0), + api_name: '', + }), + fleetShipsDataSelectorFactory: (id: number) => () => mocks.fleets[id] ?? [], + fleetShipsEquipDataSelectorFactory: (id: number) => () => mocks.equips[id] ?? [], +})) + +vi.mock('../redux', () => ({ + tankTransportMapsSelector: (state: PoiRootState) => + (state.ext as { tankTransportMaps?: number[] } | undefined)?.tankTransportMaps ?? [], +})) + +import TransportPoints from './transport-points' + +const memberShip = (api_id: number, api_ship_id: number, nowhp = 10, maxhp = 10) => + ({ api_id, api_ship_id, api_nowhp: nowhp, api_maxhp: maxhp }) as APIGetMemberShip2Response + +const masterShip = (api_stype: number) => ({ api_stype }) as APIMstShip + +const memberItem = (api_slotitem_id: number) => + ({ api_slotitem_id }) as APIGetMemberSlotItemResponse + +const masterItem = (api_type: number) => + ({ api_type: [0, 0, api_type, 0, 0] }) as APIMstSlotitem + +const slot = (itemId: number, itemType: number): ProphetEquipEntry => + [memberItem(itemId), masterItem(itemType), undefined] + +const ship = (api_stype: number, api_id: number): ProphetFleetEntry => + [memberShip(api_id, api_stype), masterShip(api_stype)] + +const destroyer = (api_id: number): ProphetFleetEntry => ship(2, api_id) + +const setFleet = ( + id: number, + ships: ProphetFleetEntry[], + slots: (ProphetEquipEntry | undefined)[][], +): void => { + mocks.fleets[id] = ships + mocks.equips[id] = slots +} + +beforeEach(() => { + mocks.fleets = {} + mocks.equips = {} + mocks.state = { + sortie: { combinedFlag: 1, escapedPos: [], sortieMapId: 624 }, + info: { fleets: {} }, + const: { $maps: { 201: {} } }, + ext: { tankTransportMaps: [625] }, + } as unknown as PoiRootState +}) + +afterEach(cleanup) + +describe('TransportPoints', () => { + it('shows both tables while in port', () => { + setFleet(0, [destroyer(101)], [[slot(68, 24)]]) + render() + expect(screen.getByText('Transport Point')).toBeInTheDocument() + expect(screen.getByText('Tank Transport Point')).toBeInTheDocument() + expect(screen.getByTestId('fa-database').parentElement).toHaveTextContent('[13]') + expect(screen.getByTestId('fa-truck').parentElement).toHaveTextContent('[9]') + }) + + it('shows only the tank table on a tank map once sortied', () => { + setFleet(0, [destroyer(101)], [[slot(68, 24)]]) + mocks.state.sortie.sortieMapId = 625 + render() + expect(screen.getByText('Tank Transport Point')).toBeInTheDocument() + expect(screen.queryByText('Transport Point')).not.toBeInTheDocument() + expect(screen.getByTestId('fa-truck').parentElement).toHaveTextContent('[9]') + }) + + it('accepts a string map id from the api', () => { + setFleet(0, [destroyer(101)], [[slot(68, 24)]]) + mocks.state.sortie.sortieMapId = '625' as unknown as number + render() + expect(screen.getByText('Tank Transport Point')).toBeInTheDocument() + }) + + it('shows only the normal table on a non-tank map once sortied', () => { + setFleet(0, [destroyer(101)], [[slot(68, 24)]]) + mocks.state.sortie.sortieMapId = 624 + render() + expect(screen.getByText('Transport Point')).toBeInTheDocument() + expect(screen.queryByText('Tank Transport Point')).not.toBeInTheDocument() + }) + + it('hides the indicator when there is no transport cargo', () => { + setFleet(0, [destroyer(101)], [[]]) + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('hides a zero-total table even when cargo is present', () => { + // Ship type 1 (base 0) with a ration (category 43, base 1): + // normal 1, but tank floor(1 * 0.75) = 0. + setFleet(0, [ship(1, 101)], [[slot(145, 43)]]) + render() + expect(screen.getByTestId('fa-database').parentElement).toHaveTextContent('[1]') + expect(screen.queryByTestId('fa-truck')).not.toBeInTheDocument() + }) + + it('renders nothing on a tank map when the tank total is zero', () => { + setFleet(0, [ship(1, 101)], [[slot(145, 43)]]) + mocks.state.sortie.sortieMapId = 625 + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('hides the indicator outside event maps', () => { + setFleet(0, [destroyer(101)], [[slot(68, 24)]]) + mocks.state.const = { $maps: {} } + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('shows deliverable vs planned and floors the A-rank tooltip', () => { + mocks.state.info.fleets = { + 0: { api_ship: [101, 102], api_name: '' }, + 1: { api_ship: [], api_name: '' }, + } + mocks.state.sortie.escapedPos = [1] + setFleet(0, [destroyer(101), destroyer(102)], [[slot(68, 24)], [slot(68, 24)]]) + render() + // 13 delivered / 26 planned; 9 delivered / 19 planned in the tank table. + expect(screen.getByTestId('fa-database').parentElement).toHaveTextContent('[13 / 26]') + expect(screen.getByTestId('fa-truck').parentElement).toHaveTextContent('[9 / 19]') + // floor(13 * 0.7) = 9 and floor(9 * 0.7) = 6. + expect(screen.getByText('A_rank9')).toBeInTheDocument() + expect(screen.getByText('A_rank6')).toBeInTheDocument() + }) +}) diff --git a/src/views/transport-points.tsx b/src/views/transport-points.tsx new file mode 100644 index 0000000..36c7eed --- /dev/null +++ b/src/views/transport-points.tsx @@ -0,0 +1,120 @@ +import FontAwesome from 'react-fontawesome' +import React from 'react' +import { createSelector } from 'reselect' +import _ from 'lodash' +import { useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' +import { Tooltip } from 'views/components/etc/overlay' + +import { SortieState, type SortieStateValue } from '../utils/constants' +import { tankTransportMapsSelector } from '../redux' +import { selectFleetsEquips } from '../selectors' +import { calculateTransport, type TransportMode } from '../utils/transport' + +const StatGroup = styled.span` + margin-left: 1ex; + margin-right: 1ex; + + /* the friend FleetTitle resets the trailing margin on its own last stat */ + &:last-child { + margin-right: 0; + } + + .svg-inline--fa { + margin-right: 1ex; + } +` + +const modeDisplay: Record = { + normal: { icon: 'database', label: 'Transport Point' }, + tank: { icon: 'truck', label: 'Tank Transport Point' }, +} + +// The api hands the map id over as a string on some responses. +const isTankTransportMap = ( + sortieMapId: number | undefined, + tankTransportMaps: readonly number[], +): boolean => Number.isFinite(sortieMapId) && tankTransportMaps.includes(sortieMapId as number) + +const inEventSelector = createSelector( + [(state: PoiRootState) => state.const?.$maps], + (maps = {}) => Object.keys(maps).some((mapId) => +mapId > 100), +) + +const escapedShipIdSelector = createSelector( + [ + (state: PoiRootState) => state.sortie.escapedPos ?? [], + (state: PoiRootState) => state.sortie.combinedFlag ?? 0, + (state: PoiRootState) => state, + ], + (escapedPos: number[], combinedFlag: number, state: PoiRootState) => { + if (combinedFlag > 0) { + const shipIds = _.flatMap([0, 1], (fleetId) => + state.info?.fleets?.[fleetId]?.api_ship ?? [], + ) + return escapedPos.map((pos) => shipIds[pos]) + } + return [] + }, +) + +interface TransportPointsProps { + sortieState: SortieStateValue +} + +const TransportPoints: React.FC = ({ sortieState }) => { + const { t } = useTranslation('poi-plugin-prophet') + const sortieMapId = useSelector((state: PoiRootState) => state.sortie.sortieMapId) + const escapedShipIds = useSelector(escapedShipIdSelector) + const inEvent = useSelector(inEventSelector) + const tankTransportMaps = useSelector(tankTransportMapsSelector) + // TP comes from the Poi fleet/equipment pairs, not the lib-battle view models. + // selectFleetsEquips rebuilds its nested arrays, so compare them deeply to + // keep the selector reference stable between identical states. + const { fleets, equips } = useSelector(selectFleetsEquips, _.isEqual) + + if (!inEvent) return null + + // in port the map is not decided yet, so both tables are shown side by side; + // once sortied only the one the map actually uses is relevant + const shownModes: TransportMode[] = + sortieState === SortieState.InPort + ? ['normal', 'tank'] + : isTankTransportMap(Number(sortieMapId), tankTransportMaps) + ? ['tank'] + : ['normal'] + + return ( + <> + {shownModes.map((mode) => { + const { icon, label } = modeDisplay[mode] + const { planned, deliverable, hasTransportCargo } = + calculateTransport(fleets, equips, { escapedShipIds, mode }) + return ( + hasTransportCargo && planned > 0 && ( + + +
{t(label)}
+ {`${t('A_rank')}${Math.floor(deliverable * 0.7)}`} + + } + > + + [ + {planned !== deliverable && {`${deliverable} / `}} + {planned}] + +
+
+ ) + ) + })} + + ) +} + +export default TransportPoints diff --git a/vitest.config.ts b/vitest.config.ts index 39bcdbf..0ae1674 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,31 @@ import { defineConfig } from 'vitest/config' const configDir = path.dirname(fileURLToPath(import.meta.url)) +// Modules Poi provides at runtime but that are not installed in this package. +// Tests always replace them with vi.mock factories; resolving them to a virtual +// id keeps Vite from failing on the missing import. +const hostOnlyModules = [ + 'react-redux', + 'react-fontawesome', + 'views/utils/selectors', + 'views/components/etc/overlay', +] + +const hostModuleStubPlugin = { + name: 'prophet-host-module-stubs', + enforce: 'pre' as const, + resolveId(id: string): string | undefined { + if (hostOnlyModules.includes(id)) return `\0prophet-host:${id}` + return undefined + }, + load(id: string): string | undefined { + if (id.startsWith('\0prophet-host:')) return 'export default {}' + return undefined + }, +} + export default defineConfig({ + plugins: [hostModuleStubPlugin], resolve: { alias: { 'views/env-parts/i18next': path.resolve(configDir, './test/mocks/i18next.ts'),