diff --git a/UPDATES.md b/UPDATES.md index c80d71f..435a669 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,7 +1,13 @@ --- -updatedAt: 2026-09-29T00:00:00Z +updatedAt: 2026-09-30T00:00:00Z --- +## 2026년 9월 30일 + +### 퍼포먼스 패시브 종합력 계산 수정 + +`FIX` 지정 인원의 퍼포먼스를 올리는 패시브가 전체 능력치 합계 대신 개화를 반영한 기본 퍼포먼스 순으로 대상을 선택하도록 수정했습니다. 일부 편성에서 게임과 종합력이 다르게 표시되던 문제를 해결했습니다. + ## 2026년 9월 29일 ### 두근두근 블루 스플래시! 이벤트 diff --git a/docs/research/unit-power-target-mismatch-2026-09-30.md b/docs/research/unit-power-target-mismatch-2026-09-30.md new file mode 100644 index 0000000..6ded787 --- /dev/null +++ b/docs/research/unit-power-target-mismatch-2026-09-30.md @@ -0,0 +1,112 @@ +# 종합력 불일치 조사 — 2026-09-30 + +## 제공 화면의 차이 + +편성: 후부키(기본 ★5) 5개화, 마츠리(기본 ★5) 4개화, 보탄(기본 ★5) 5개화, 아냐(수영복 ★5) 5개화, 코보(기본 ★5) 4개화. 리더 의상은 벨즈의 레디언트 치즈. + +| 항목 | 계산기 | 게임 | 게임 − 계산기 | +| --- | ---: | ---: | ---: | +| 멤버 파라미터(기본 + 개화) | 129,771 | 129,771 | 0 | +| 의상 | 71,524 | 71,524 | 0 | +| 보드 | 123,637 | 123,637 | 0 | +| 패시브 | 46,712 | 47,017 | 305 | +| 메모리 | 8,442 | 8,442 | 0 | +| 멤버 강화 | 21,334 | 21,352 | 18 | +| 종합력 | 401,420 | 401,743 | 323 | + +## 확인된 원인 + +`src/lib/engine/unit-power.ts`의 `targetIndexes()`는 지정 인원 패시브 대상을 개화 후 전체 파라미터 합계 내림차순으로 고른다. 이번 편성에서 대상 선택이 달라질 수 있는 것은 아냐의 퓨어 2명 퍼포먼스 +43% 패시브다. 나머지 패시브는 조건에 맞는 인원이 정확히 2명이거나 자기 자신을 대상으로 한다. + +| 퓨어 멤버 | 개화 후 전체 파라미터 | 개화 후 퍼포먼스 | +| --- | ---: | ---: | +| 후부키 | 25,975 | 10,909 | +| 아냐 | 25,867 | 11,381 | +| 코보 | 26,031 | 10,673 | + +현재 코드는 코보·후부키를 선택한다. 소스 파일을 변경하지 않고 Node의 메모리상 모듈에서 정렬 기준만 해당 능력치로 바꾸면 아냐·후부키가 선택되며, 패시브 합계가 **46,712 → 47,017**로 게임 화면과 정확히 일치한다. 다른 기본·의상·메모리 계산은 동일하다. 패시브 증가율은 대상별로 합산한 뒤 능력치마다 올림하므로, 아냐 패시브를 단독 계산한 차이 304와 편성 전체 차이 305는 다르다. + +멤버 강화는 메모리를 제외한 파라미터·패시브·의상·보드 합계에 5.74%를 적용하므로 패시브 오차가 강화 항목에도 전파된다. 최초 조사에서는 멤버별 보드 분배 원본이 없어 강화값의 멤버별 올림까지 재현하지 못했으나, 아래 추가 검증에서 백업을 사용하여 정확히 재현했다. + +## DB 확인과 검증 범위 + +- `scripts/game-db-source.mjs`로 현재 해시 고정 Android DB를 검증해서 읽었다. 마스터 버전: `546b8e2e37db368c6731e148b88611cef105bbaa66ff567f5c1584711ef979a4`. +- `LivePassiveSkillLevel` 및 `LivePassiveSkillEffect`에서 `card-03005-5-uniq-0084-00`의 레벨 2 효과가 퍼포먼스 +430‰임을 확인했다. +- `LiveSkillEffectTarget`의 대상은 퓨어 2명이다. 이 레코드에는 대상 정렬 기준이 없어 DB만으로 일반적인 선정 규칙을 확정할 수 없다. +- 이번 화면은 아냐·후부키 적용 결과를 지지한다. 모든 편성에서 해당 능력치 순인지, 보드 등을 포함한 다른 기준인지 확정하려면 추가 실측 또는 게임 실행 코드 확인이 필요하다. +- 기존 `unit-power.test.ts` 9개는 모두 통과하지만, 마지막 테스트가 전체 파라미터 순이라는 기존 가정을 명시적으로 고정하고 있어 이번 불일치를 잡지 못한다. +- 조사는 계산식과 기존 데이터 읽기 및 메모리상 비교 실험으로 수행했다. 서비스 계산 코드, 데이터 추출 기준, 배포는 변경하지 않았다. + +## 사용자 백업 및 개별 카드 화면 추가 검증 + +사용자가 제공한 `holodori-backup-2026-09-29.json`의 보드 노드, 커넥트 카드 배치와 개화, 메모리 35개(65‰), 멤버 강화 5.74%를 사용했다. 곡은 Play Dice! (벨즈 솔로), 리더는 벨즈, 의상은 기존과 같다. 원본 백업은 저장소로 복사하지 않았다. + +새 편성 화면 순서는 마츠리·코보·보탄·아냐·후부키다. `calculateBoardEffects()`로 실제 저장 설정을 계산한 뒤, `calculateUnitPower()`의 대상 정렬만 메모리상에서 해당 능력치 내림차순으로 바꾸고 멤버별 중간값을 출력했다. 서비스 소스 파일은 수정하지 않았다. + +| 멤버 | 기존 전체 파라미터 정렬 | 퍼포먼스 정렬 실험 | 게임 카드 아래 표시 | +| --- | ---: | ---: | ---: | +| 마츠리 | 86,694 | 86,694 | 86,694 | +| 코보 | 83,648 | 78,796 | 78,796 | +| 보탄 | 74,998 | 74,998 | 74,998 | +| 아냐 | 70,727 | 75,902 | 75,902 | +| 후부키 | 85,353 | 85,353 | 85,353 | +| 합계 | 401,420 | 401,743 | 401,743 | + +게임 화면과 대조한 멤버별 계산 내역: + +- 코보의 패시브 합계: 12,921 → 8,332 (−4,589), 멤버 강화: 4,449 → 4,186 (−263). +- 아냐의 패시브 합계: 0 → 4,894 (+4,894), 멤버 강화: 3,748 → 4,029 (+281). +- 편성 패시브 합계: +305, 멤버 강화 합계: +18, 종합력 합계: +323. +- 변경 후 `passiveTotal = 47017`, `memberPowerUpTotal = 21352`, `overallPower = 401743`. 다른 모든 항목도 기존 게임 상세 화면과 일치한다. + +다섯 카드가 모두 일치하므로 단순히 총합만 우연히 일치한 것이 아니라, 이번 편성에서 아냐의 패시브가 아냐·후부키에 적용되고 코보에는 적용되지 않는다는 것을 뒷받침한다. 새 화면 중앙의 2,705,919는 유닛 스코어이므로 종합력과 직접 비교하지 않았다. + +### 개별 카드 상세로 구분되는 가설 + +| 멤버 | 게임 상세 토털 | 게임 상세 퍼포먼스 | +| --- | ---: | ---: | +| 후부키 | 39,480 | 15,250 | +| 아냐 | 39,357 | 15,878 | +| 코보 | 39,528 | 15,034 | + +게임 상세 토털도 코보 > 후부키 > 아냐이므로 **해당 화면의 토털 상위 2명을 고른다는 가설 역시 이번 관측과 맞지 않는다**. 퍼포먼스는 아냐 > 후부키 > 코보로 실제 적용 대상과 일치한다. 새 편성의 퓨어 등장 순서는 코보·아냐·후부키이므로, 단순히 앞의 두 퓨어를 고른다는 가설도 맞지 않는다. + +남은 한계: 개화 후 기본 퍼포먼스 순위와 게임 상세 퍼포먼스 순위가 같으므로, 어느 단계의 퍼포먼스를 기준으로 하는지는 이 자료만으로 구별되지 않는다. 이를 구분하려면 두 기준의 순위가 뒤집히는 다른 편성이 필요하다. 센스·테크닉·전체 파라미터 증가 및 스코어 서포트 패시브의 일반 규칙은 별도로 검증해야 한다. + +## 두 퍼포먼스 기준을 구분할 추가 실험 편성 + +사용자 요청으로 같은 백업의 보유 퓨어 카드에서 기본 퍼포먼스와 보드 반영 퍼포먼스 순위가 역전되는 쌍을 검색했다. 기존 편성에서 **코보만 기본 ★5 후레아 2개화로 교체**하면 된다. 후레아 카드 ID는 `card-00021-5-uniq-0017-00`이다. 백업은 소유 여부·개화만 기록하므로 실제 카드 레벨은 확인이 필요하다. 아래 값은 모두 Lv.80 기준이다. + +편성: 마츠리 4개화 / 후레아 2개화 / 보탄 5개화 / 수영복 아냐 5개화 / 후부키 5개화. 아냐 외에는 기본 ★5 카드. Play Dice!, 벨즈 리더, 레디언트 치즈 의상, 백업 보드·커넥트, 메모리 35개 및 강화 5.74% 유지. + +카드 상세 표시값의 후보식 `개화 후 스탯 + 리더·곡 조건을 넣지 않은 단일 카드 보드 고정치 + ceil(개화 후 스탯 × 해당 보드 비율)`은 제공된 후부키·아냐·코보의 세 능력치, 총 9개 표시값 모두와 일치했다. `calculateBoardEffects(summary, memberBoards, [card], '', null, connections)`로 해당 보드 값을 계산했다. 이 일치는 관측된 세 카드에 대한 검증이며 모든 카드 상세 표시 규칙을 일반적으로 증명한 것은 아니다. + +| 퓨어 후보 | 개화 후 기본 퍼포먼스 | 보드 반영 상세 퍼포먼스 | +| --- | ---: | ---: | +| 수영복 아냐 | 11,381 | 15,878 (기존 화면 확인) | +| 기본 후레아 | 11,381 | 14,581 (백업으로 예측) | +| 기본 후부키 | 10,909 | 15,250 (기존 화면 확인) | + +아냐·후레아의 기본 퍼포먼스 동률은 두 명 모두 상위 2명에 들어가므로 결과에 영향을 주지 않는다. 경계인 후레아·후부키의 순위가 역전된다. + +| 예측 항목 | 기본 퍼포먼스 순 선정 | 상세 퍼포먼스 순 선정 | +| --- | ---: | ---: | +| 아냐 패시브 대상 | 아냐·후레아 | 아냐·후부키 | +| 마츠리 종합력 | 86,694 | 86,694 | +| 후레아 종합력 | 75,814 | 70,639 | +| 보탄 종합력 | 74,998 | 74,998 | +| 아냐 종합력 | 75,902 | 75,902 | +| 후부키 종합력 | 80,393 | 85,353 | +| 패시브 합계 | 42,530 | 42,327 | +| 멤버 강화 | 20,922 | 20,910 | +| 종합력 합계 | 393,801 | 393,586 | + +예측은 서비스 파일을 수정하지 않고 기존 엔진을 메모리상에서 변형하여 대상 정렬만 바꾼 결과다. 효과량 자체는 두 가설 모두 개화 후 기본 스탯을 기준으로 계산한다. 현재의 전체 파라미터 정렬 코드도 우연히 393,586을 출력하지만 아냐 70,727 / 후레아 75,814로 내부 적용 대상이 다르다. 따라서 총합만 대조하지 말고 카드 아래 개별 종합력이 보이는 화면도 확인해야 한다. + +요청할 실측: 후레아 카드 상세(Lv.80, 2개화, 퍼포먼스 14,581 예상), 위 편성의 카드별 종합력 화면, 유닛 스코어 상세의 종합력·패시브·강화 항목. 백업 이후 보드나 강화가 변했다면 같은 시점 설정으로 예측을 다시 계산한다. 관측이 한 가설과 일치해도 다른 모든 스킬 유형의 일반 규칙까지 확정하는 것은 아니다. + +## 사용자 검증 및 수정 + +사용자는 후레아의 스펙이 예상과 일치하며 기본 수치 기준으로 적용됨을 확인했다. 이후 정렬 기준 수정 요청에 따라 지정 인원 퍼포먼스 증가 패시브의 대상을 **개화 후 기본 퍼포먼스** 내림차순으로 선택하도록 수정했다. 보드·의상·다른 패시브는 대상 선정 수치에 포함하지 않는다. 아직 검증하지 않은 테크닉·센스·전체 파라미터 및 스코어 서포트의 선정 기준은 이번 변경에 포함하지 않았다. + +기존 전체 파라미터 우선 가정 테스트를 바로잡고, 코보 편성 401,743 및 후레아 편성 393,801의 항목별 결과를 회귀 테스트에 추가했다. 두 편성을 역순으로 넣어도 같은 값인지 검증한다. 사용자 백업 전체 대신 재현에 필요한 효과별 보드 수치만 테스트에 보존했다. diff --git a/src/lib/engine/unit-power.test.ts b/src/lib/engine/unit-power.test.ts index 0058cef..25bcf26 100644 --- a/src/lib/engine/unit-power.test.ts +++ b/src/lib/engine/unit-power.test.ts @@ -149,7 +149,7 @@ test('조건부 기수 패시브는 복수 소속 멤버도 대상으로 삼는 assert.equal(result.passiveTotal, 20); }); -test('지정 인원 능력치 패시브는 해당 능력치가 아닌 전체 파라미터 합계 상위 멤버에게 적용한다', () => { +test('지정 인원 퍼포먼스 패시브는 전체 파라미터가 아닌 기본 퍼포먼스 상위 멤버에게 적용한다', () => { const source = { ...card('source', '퓨어 타입 2명의 퍼포먼스가 10% UP'), type: '해피' @@ -173,5 +173,46 @@ test('지정 인원 능력치 패시브는 해당 능력치가 아닌 전체 파 { card: card('other', ''), awakening: 0 } ]); - assert.equal(result.passiveTotal, 25); + assert.equal(result.passiveTotal, 35); +}); + +test('2026-09-30 실측 편성은 보드 보정 전 개화 반영 퍼포먼스로 대상을 선정한다', () => { + const catalog = JSON.parse(readFileSync(new URL('../data/cards-summary.json', import.meta.url), 'utf8')) as { + cards: (UnitPowerCard & { details: { costume: { name: string; description: string } } })[]; + }; + const findCard = (id: string) => { + const found = catalog.cards.find((entry) => entry.id === id); + assert.ok(found, id); + return found; + }; + // Effective board stats only; no account backup is retained in this fixture. + const commonIds = [ + 'card-00006-5-uniq-0007-00', 'card-00007-5-uniq-0008-00', + 'card-00032-5-uniq-0026-00', 'card-03005-5-uniq-0084-00' + ]; + const commonFlats = [[5460, 5863, 6076], [5572, 5772, 6235], [5572, 5772, 6195], [5592, 5792, 6005]]; + for (const scenario of [ + { id: 'card-03009-5-uniq-0041-00', awakening: 4, flat: [5492, 5692, 6205], + expected: { overallPower: 401743, baseParameterTotal: 117966, potentialTotal: 11805, + passiveTotal: 47017, outfitTotal: 71524, boardTotal: 123637, memoryTotal: 8442, memberPowerUpTotal: 21352 } }, + // Flare outranks Fubuki before board bonuses, but trails her after bonuses. + { id: 'card-00021-5-uniq-0017-00', awakening: 2, flat: [4295, 4495, 4873], + expected: { overallPower: 393801, baseParameterTotal: 117817, potentialTotal: 11790, + passiveTotal: 42530, outfitTotal: 72445, boardTotal: 119865, memoryTotal: 8432, memberPowerUpTotal: 20922 } } + ]) { + const ids = [...commonIds, scenario.id]; + const awakenings = [5, 4, 5, 5, scenario.awakening]; + const flats = [...commonFlats, scenario.flat]; + const members = ids.map((id, index) => ({ card: findCard(id), awakening: awakenings[index] })); + const board = { + flatByCardId: Object.fromEntries(ids.map((id, index) => [id, { + performance: flats[index][0], technique: flats[index][1], sense: flats[index][2] + }])), + rateByCardId: Object.fromEntries(ids.map((id) => [id, { performance: 28, technique: 28, sense: 28 }])) + }; + for (const order of [members, [...members].reverse()]) { + assert.deepEqual(calculateUnitPower(order, findCard('card-04012-5-uniq-0051-00').details.costume, board, 65, 5.74), + { ...scenario.expected, outfitScoreSupportPercent: 0 }, scenario.id); + } + } }); diff --git a/src/lib/engine/unit-power.ts b/src/lib/engine/unit-power.ts index 9848258..f30c040 100644 --- a/src/lib/engine/unit-power.ts +++ b/src/lib/engine/unit-power.ts @@ -86,12 +86,17 @@ function targetIndexes( const target = effectText.match(/^(.+?)\s*(\d+)명의\s*(?:전체 파라미터|퍼포먼스|테크닉|센스)/); if (!target) return []; const count = Number(target[2]); + // Verified in-game: performance buffs rank by awakened base performance, + // before board/outfit/passive bonuses. Keep other effect rankings unchanged. + const rankByPerformance = description.includes('퍼포먼스'); return members .map((member, index) => ({ member, index })) .filter(({ member }) => skillTargetBelongsTo(member.card, target[1])) .sort((left, right) => { - const rightTotal = stats.reduce((sum, key) => sum + parameters[right.index][key], 0); - const leftTotal = stats.reduce((sum, key) => sum + parameters[left.index][key], 0); + const rightTotal = rankByPerformance ? parameters[right.index][stat] + : stats.reduce((sum, key) => sum + parameters[right.index][key], 0); + const leftTotal = rankByPerformance ? parameters[left.index][stat] + : stats.reduce((sum, key) => sum + parameters[left.index][key], 0); return rightTotal - leftTotal || left.member.card.id.localeCompare(right.member.card.id); }) .slice(0, count)