diff --git a/docs/transport-formula-notes.md b/docs/transport-formula-notes.md new file mode 100644 index 0000000..013193e --- /dev/null +++ b/docs/transport-formula-notes.md @@ -0,0 +1,169 @@ +# Transport Point (TP) Calculation Rules + +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. + +Source check date: 2026-09-01; supplementary check: 2026-09-15. + +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 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) (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 = sum of ship-type base TP +ItemBase = sum of equipment-category base TP +Base = ShipBase + ItemBase +``` + +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. + +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 rank: floor(NormalRaw) +A rank: floor(NormalRaw × 0.7) +B or below: 0 +``` + +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. + +### Ship-type base TP + +| Ship type | S rank base value | +| --- | ---: | +| 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 | +| --- | ---: | +| 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 | + +Equipment improvement level does not change TP. + +## 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. + +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 +``` + +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 +``` + +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. + +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 rank: floor(LandingRaw) +A rank: floor(LandingRaw × 0.7) +B or below: 0 +``` + +### Specific equipment bonus + +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. + +| Equipment | Normal base value | Bonus | Tank transport S value | +| --- | ---: | ---: | ---: | +| 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 | +| 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 +``` + +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`. + +## Which ships count toward 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 + +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 +``` + +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 +Submarine aircraft carrier (SSV): 1 × 0.75 = 0.75 +Submarine tender (AS): 7 × 0.75 = 5.25 +``` + +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 + +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. + +- 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. diff --git a/src/utils/transport.test.ts b/src/utils/transport.test.ts index c68d8b5..0832b9a 100644 --- a/src/utils/transport.test.ts +++ b/src/utils/transport.test.ts @@ -1,124 +1,250 @@ 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 { 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 { - getTransportPoint, - getTransportPointFromFleets, + calculateTransport, parseTankTransportMaps, } 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 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 + +// 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)), + )), }) -const equipSlots = (...items: ApiSlotItemLike[]): [ApiSlotItemLike][] => - items.map((item) => [item]) - -// [api_stype, api_ship_id, equipment 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 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 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, + }) + const noneEligible = fleetsOf([[[2, 1, [75], 0], [3, 487, [999], 0]]]) + expect(calculateTransport(noneEligible.fleets, noneEligible.equips).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', () => { + const { fleets, equips } = fleetsOf([[[2, 1, []]]]) + expect(calculateTransport(fleets, equips)).toEqual({ + planned: 5, deliverable: 5, hasTransportCargo: false, + }) + expect(calculateTransport([], [])).toEqual({ + planned: 0, deliverable: 0, hasTransportCargo: false, + }) + expect(calculateTransport([[]], [[]], { mode: '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('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('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))] + 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) + }, + ) - // 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('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('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]] + 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, + }) + }) - expect(getTransportPoint(ships, equips)).toEqual({ total: 8, actual: 8 }) - expect(getTransportPoint(ships, equips, [], 'tank')).toEqual({ total: 24, actual: 24 }) + 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('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('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, + }) + }) - // 鬼怒改二 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.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, equips } = fleetsOf([[[shipType, 1, []]]]) + expect(calculateTransport(fleets, equips).planned).toBe(base) + expect(calculateTransport(fleets, equips, { mode: 'tank' }).planned).toBe(Math.floor(base * 0.75)) }) - // 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([ + [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, itemCategory, normal, tank) => { + // Four copies expose fractional values without per-item rounding. + 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(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, 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, equips, { mode: 'tank' }).planned).toBe(20) + }) + + it('floors main and escort fleets separately, including after eligibility filtering', () => { + 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()], [equips.flat()], { mode: 'tank' }).planned).toBe(16) + }) + + it('keeps the Kinu bonus unscaled', () => { + 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 expected = mode === 'normal' + ? { planned: 12, deliverable: 10, hasTransportCargo: false } + : { planned: 10, deliverable: 9, hasTransportCargo: false } + 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(together.fleets, together.equips, { mode }).planned).toBe(sameFleet) + expect(calculateTransport(together.fleets, together.equips, { mode }).deliverable) + .toBe(expected.deliverable) + expect(calculateTransport(togetherReversed.fleets, togetherReversed.equips, { mode }).deliverable) + .toBe(expected.deliverable) + + 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 { 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(fleets, equips, { 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 +259,17 @@ 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 { fleets, equips } = fleetsOf([fleet.slice(0, 6), fleet.slice(6)]) + expect(calculateTransport(fleets, equips)).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, equips, { mode: '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 +284,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 { fleets, equips } = fleetsOf([fleet.slice(0, 6), fleet.slice(6)]) + expect(calculateTransport(fleets, equips, { mode: '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..435938e 100644 --- a/src/utils/transport.ts +++ b/src/utils/transport.ts @@ -1,6 +1,7 @@ import _ from 'lodash' -import type { Ship } from 'poi-lib-battle' -import type { FriendShipRaw } from '../views/ship-view/types' +import type { APIGetMemberShip2Response } from 'kcsapi/api_get_member/ship2/response' + +import type { ProphetEquipEntry, ProphetFleetEntry } from '../types' /** * Transport operations come in two flavours since 2026: @@ -52,7 +53,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 +77,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 +110,105 @@ const TPByShipType: Record = { 20: 7, } -type ItemLike = { api_slotitem_id?: number; api_type?: number[] } | null | undefined +export interface TransportResult { + planned: number + deliverable: number + /** Cargo presence is separate from the ship's own transport capacity. */ + hasTransportCargo: boolean +} -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 TransportOptions { + mode?: TransportMode + /** Member ship IDs that have retreated and can no longer deliver. */ + escapedShipIds?: readonly 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 +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 + 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 + }) }) -} -export interface TPResult { - total: number - actual: number + // Preserve per-fleet rounding; do not round individual contributions. + return Math.floor((base * numerator + itemBonus * denominator) / denominator) } -interface ShipTP { - ignored: boolean - ship: number - bonus: number - equip: number +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((fleet) => fleet.some( + (pair) => pair != null && include(pair[0]) && pair[0].api_ship_id === KINU_KAI_NI, + )) ? KINU_BONUS : 0 + + return fleetPoints + fleetBonus } -// 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)) - +/** + * 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, + equips: TransportEquips, + { mode = 'normal', escapedShipIds = [] }: TransportOptions = {}, +): TransportResult => { + const rule = rules[mode] return { - total: equipTP ? _.sumBy(fleets, fleetTP) : 0, - actual: _.sumBy(fleets, (fleet) => - fleetTP(fleet.filter(({ ignored }) => !ignored)), + 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), + ), + )), } } - -interface TransportShipData { - api_id: number - api_nowhp: number - api_maxhp: number - api_stype: number - api_ship_id: number -} - -type EquipSlotTuple = [ItemLike, { api_type?: number[] }?, ...unknown[]] - -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) - }), - })), - ]) -} - -type ShipLike = Ship | null | undefined - -export interface FleetTransportPointOptions { - escapedShipIds?: number[] - mode?: TransportMode -} - -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), - ), - })), - ), - ) -} diff --git a/src/views/battle-view-area.tsx b/src/views/battle-view-area.tsx index 5954c15..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,15 @@ 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 type { ProphetBattleResult } from '../types' import type { SortieStateValue } from '../utils/constants' import { battleSpotKey, enemyTitle as buildEnemyTitle, friendTitle as buildFriendTitle, - isTankTransportMap, - transportPoints, } from './battle-view-model' -import type { TPResult } from '../utils/transport' const FleetsContainer = styled.div<{ horizontalLayout?: boolean }>` display: flex; @@ -130,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)[] @@ -221,23 +194,6 @@ const BattleViewArea: FC = ({ ) const enemyTitle = buildEnemyTitle({ sortieState, showEnemyTitle, storedEnemyTitle: historyTitle }) - const escapedShipIds = useSelector(escapedShipIdSelector) - const inEvent = useSelector(inEventSelector) - const tankTransportMaps = useSelector(tankTransportMapsSelector) - const TP = transportPoints({ inEvent, mainFleet, escortFleet, 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', ) @@ -328,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 73af191..643d32d 100644 --- a/src/views/battle-view-model.test.ts +++ b/src/views/battle-view-model.test.ts @@ -1,107 +1,37 @@ -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) - }) -}) +import { describe, expect, it } from 'vitest' + +import { SortieState } from '../utils/constants' +import { + battleSpotKey, + enemyTitle, + friendTitle, +} 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') + }) +}) diff --git a/src/views/battle-view-model.ts b/src/views/battle-view-model.ts index ee44e09..ad88b83 100644 --- a/src/views/battle-view-model.ts +++ b/src/views/battle-view-model.ts @@ -1,10 +1,4 @@ -import type { Ship } from 'poi-lib-battle' - import { combinedFleetType, SortieState, type SortieStateValue } from '../utils/constants' -import { - getTransportPointFromFleets, - type TPResult, -} from '../utils/transport' export interface BattleTitleInput { sortieState: SortieStateValue @@ -43,33 +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) - -const noTP: TPResult = { total: 0, actual: 0 } - -/** - * 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, - mainFleet = [], - escortFleet = [], - escapedShipIds = [], -}: { - inEvent: boolean - mainFleet?: (Ship | null)[] - escortFleet?: (Ship | null)[] - escapedShipIds?: number[] -}): { normal: TPResult; tank: TPResult } => { - if (!inEvent) return { normal: noTP, tank: noTP } - const fleets = [mainFleet, escortFleet] - return { - normal: getTransportPointFromFleets(fleets, { escapedShipIds }), - tank: getTransportPointFromFleets(fleets, { 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'),