From abd05656067d0f87a410ace493750b900b4b8802 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:49 +0900 Subject: [PATCH 01/41] Update EquipmentItemSheet.csv from Google Sheets --- Lib9c/TableCSV/EquipmentItemSheet.csv | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Lib9c/TableCSV/EquipmentItemSheet.csv b/Lib9c/TableCSV/EquipmentItemSheet.csv index 1e88054e1d5..58a5be96b0f 100644 --- a/Lib9c/TableCSV/EquipmentItemSheet.csv +++ b/Lib9c/TableCSV/EquipmentItemSheet.csv @@ -358,3 +358,16 @@ id,_name,item_sub_type,grade,elemental_type,set_id,stat_type,stat_value,attack_r 10582000,Transcendent Ring,Ring,8,Water,13,DEF,2337152,0,10572000,800000000 10583000,Transcendent Ring,Ring,8,Land,14,DEF,3505728,0,10573000,800000000 10584000,Transcendent Ring,Ring,8,Wind,15,DEF,3505728,0,10574000,800000000 +10690000,Ymir Aura,Aura,9,Normal,0,ATK,10,0,10620001,4160000 +10690001,Beast Aura,Aura,9,Normal,0,ATK,10,0,10620001,4160000 +10690002,Aegis Aura,Aura,9,Normal,0,HP,10,0,10620001,4160000 +10690003,Barrage Aura,Aura,9,Normal,0,ATK,10,0,10620001,4160000 +10790001,ATK Gri (A),Grimoire,9,Normal,0,ATK,10,0,10860000,4160000 +10790002,ATK Gri (H),Grimoire,9,Normal,0,ATK,10,0,10860000,4160000 +10790003,ATK Gri (S),Grimoire,9,Normal,0,ATK,10,0,10860000,4160000 +10790004,DEF Gri(D),Grimoire,9,Normal,0,DEF,10,0,10860000,4160000 +10790005,DEF Gri(H),Grimoire,9,Normal,0,DEF,10,0,10860000,4160000 +10790006,DEF Gri(S),Grimoire,9,Normal,0,DEF,10,0,10860000,4160000 +10790007,SPD Gri(A),Grimoire,9,Normal,0,SPD,10,0,10860000,4160000 +10790008,SPD Gri(D),Grimoire,9,Normal,0,SPD,10,0,10860000,4160000 +10790009,SPD Gri(S),Grimoire,9,Normal,0,SPD,10,0,10860000,4160000 From 3efbb30bd6f96747f1ab6e1081b49abe30e76a92 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:50 +0900 Subject: [PATCH 02/41] Update EquipmentItemRecipeSheet.csv from Google Sheets --- Lib9c/TableCSV/EquipmentItemRecipeSheet.csv | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Lib9c/TableCSV/EquipmentItemRecipeSheet.csv b/Lib9c/TableCSV/EquipmentItemRecipeSheet.csv index ef69ace5afc..49a88de6ba0 100644 --- a/Lib9c/TableCSV/EquipmentItemRecipeSheet.csv +++ b/Lib9c/TableCSV/EquipmentItemRecipeSheet.csv @@ -254,3 +254,16 @@ id,result_equipment_id,material_id,material_count,required_action_point,required 333,10580000,383402,300,0,0,10800,495,105800001,105800002,,60000000,Ring 334,10480000,383302,300,0,0,10800,497,104800001,104800002,,60000000,Necklace 335,10380000,383202,300,0,0,10800,499,103800001,103800002,,60000000,Belt +336,10690000,0,0,0,0,10000000,999,106900001,,,0,Aura +337,10690001,0,0,0,0,10000000,999,106900011,,,0,Aura +338,10690002,0,0,0,0,10000000,999,106900021,,,0,Aura +339,10690003,0,0,0,0,10000000,999,106900031,,,0,Aura +340,10790001,0,0,0,0,10000000,999,107900001,,,0,Grimoire +341,10790002,0,0,0,0,10000000,999,107900011,,,0,Grimoire +342,10790003,0,0,0,0,10000000,999,107900021,,,0,Grimoire +343,10790004,0,0,0,0,10000000,999,107900031,,,0,Grimoire +344,10790005,0,0,0,0,10000000,999,107900041,,,0,Grimoire +345,10790006,0,0,0,0,10000000,999,107900051,,,0,Grimoire +346,10790007,0,0,0,0,10000000,999,107900061,,,0,Grimoire +347,10790008,0,0,0,0,10000000,999,107900071,,,0,Grimoire +348,10790009,0,0,0,0,10000000,999,107900081,,,0,Grimoire From 9859f84264b70382e93b1cedce63ad8c6aa5e9d0 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:50 +0900 Subject: [PATCH 03/41] Update EquipmentItemSubRecipeSheetV2.csv from Google Sheets --- Lib9c/TableCSV/EquipmentItemSubRecipeSheetV2.csv | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Lib9c/TableCSV/EquipmentItemSubRecipeSheetV2.csv b/Lib9c/TableCSV/EquipmentItemSubRecipeSheetV2.csv index 648afc66aa8..b8a440f941c 100644 --- a/Lib9c/TableCSV/EquipmentItemSubRecipeSheetV2.csv +++ b/Lib9c/TableCSV/EquipmentItemSubRecipeSheetV2.csv @@ -754,3 +754,16 @@ ID,required_action_point,required_gold,required_block_index,material_id,material 104800002,0,0,0,600203,10,386054,600,386070,20,1048000021,10000,0,1048000022,6000,10800,1048000023,2000,16200,1048000024,1800,21600,FALSE,1 103800001,0,0,0,600201,10,386007,300,386070,20,1038000011,10000,0,1038000012,6000,10800,1038000013,1400,16200,1038000014,1300,21600,FALSE,1 103800002,0,0,0,600203,10,386053,600,386070,20,1038000021,10000,0,1038000022,6000,10800,1038000023,2000,16200,1038000024,1800,21600,FALSE,1 +106900001,0,0,1,,,,,,,1069000011,10000,0,1069000012,10000,0,1069000013,10000,0,1069000014,10000,0,FALSE,1 +106900011,0,0,1,,,,,,,1069000111,10000,0,1069000112,10000,0,1069000113,10000,0,1069000114,10000,0,FALSE,1 +106900021,0,0,1,,,,,,,1069000211,10000,0,1069000212,10000,0,1069000213,10000,0,,,,FALSE,1 +106900031,0,0,1,,,,,,,1069000311,10000,0,1069000312,10000,0,1069000313,10000,0,,,,FALSE,1 +107900001,0,0,1,,,,,,,1079000011,10000,0,1079000012,10000,0,1079000013,10000,0,1079000014,10000,0,FALSE,1 +107900011,0,0,1,,,,,,,1079000011,10000,0,1079000012,10000,0,1079000013,10000,0,1079000114,10000,0,FALSE,1 +107900021,0,0,1,,,,,,,1079000011,10000,0,1079000012,10000,0,1079000013,10000,0,1079000214,10000,0,FALSE,1 +107900031,0,0,1,,,,,,,1079000311,10000,0,1079000312,10000,0,1079000313,10000,0,1079000314,10000,0,FALSE,1 +107900041,0,0,1,,,,,,,1079000311,10000,0,1079000312,10000,0,1079000313,10000,0,1079000414,10000,0,FALSE,1 +107900051,0,0,1,,,,,,,1079000311,10000,0,1079000312,10000,0,1079000313,10000,0,1079000514,10000,0,FALSE,1 +107900061,0,0,1,,,,,,,1079000611,10000,0,1079000612,10000,0,1079000613,10000,0,1079000614,10000,0,FALSE,1 +107900071,0,0,1,,,,,,,1079000611,10000,0,1079000612,10000,0,1079000613,10000,0,1079000714,10000,0,FALSE,1 +107900081,0,0,1,,,,,,,1079000611,10000,0,1079000612,10000,0,1079000613,10000,0,1079000814,10000,0,FALSE,1 From d192359912a753a3bbbec771314f8207785e4d73 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:50 +0900 Subject: [PATCH 04/41] Update EquipmentItemOptionSheet.csv from Google Sheets --- Lib9c/TableCSV/EquipmentItemOptionSheet.csv | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Lib9c/TableCSV/EquipmentItemOptionSheet.csv b/Lib9c/TableCSV/EquipmentItemOptionSheet.csv index 66e404e010d..d4f818a391f 100644 --- a/Lib9c/TableCSV/EquipmentItemOptionSheet.csv +++ b/Lib9c/TableCSV/EquipmentItemOptionSheet.csv @@ -2923,3 +2923,35 @@ id,stat_type,stat_min,stat_max,skill_id,skill_damage_min,skill_damage_max,skill_ 1038000022,ATK,2159988,3119984,,,,,,,, 1038000023,SPD,9860856,14243464,,,,,,,, 1038000024,,,,250000,,,13,13,,, +1069000011,DEF,5885248,8827864,,,,,,,, +1069000012,HP,24265760,36398640,,,,,,,, +1069000013,ATK,2655776,3983664,,,,,,,, +1069000014,,,,700014,100,100,40,40,,, +1069000111,HP,63869424,95804128,,,,,,,, +1069000112,ATK,3273840,4910752,,,,,,,, +1069000113,DEF,2626944,3940408,,,,,,,, +1069000114,,,,230008,1200,1200,40,40,,, +1069000211,HP,107133448,160700168,,,,,,,, +1069000212,SPD,10557760,15836632,,,,,,,, +1069000213,,,,210012,80,80,40,40,,, +1069000311,ATK,6116728,9175088,,,,,,,, +1069000312,DEF,4042016,6063016,,,,,,,, +1069000313,,,,110008,0,0,40,40,125000,125000,ATK +1079000011,,,,210015,,,12,12,,, +1079000012,ATK,4063496,6095232,,,,,,,, +1079000013,ATK,3555560,5333328,,,,,,,, +1079000014,ATK,2539680,3809520,,,,,,,, +1079000114,HIT,11594208,17391304,,,,,,,, +1079000214,SPD,8888880,13333320,,,,,,,, +1079000311,,,,220011,,,12,12,,, +1079000312,DEF,4063496,6095232,,,,,,,, +1079000313,DEF,3555560,5333328,,,,,,,, +1079000314,DEF,2539680,3809520,,,,,,,, +1079000414,HIT,11594208,17391304,,,,,,,, +1079000514,SPD,8888880,13333320,,,,,,,, +1079000611,,,,250010,,,12,12,,, +1079000612,SPD,14222224,21333296,,,,,,,, +1079000613,SPD,12444448,18666688,,,,,,,, +1079000614,ATK,2539680,3809520,,,,,,,, +1079000714,DEF,2539680,3809520,,,,,,,, +1079000814,SPD,8888880,13333320,,,,,,,, From d71b803bbb06b4924a2cdf037495143a040e9ab8 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:50 +0900 Subject: [PATCH 05/41] Update EnhancementCostSheetV3.csv from Google Sheets --- Lib9c/TableCSV/EnhancementCostSheetV3.csv | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/Lib9c/TableCSV/EnhancementCostSheetV3.csv b/Lib9c/TableCSV/EnhancementCostSheetV3.csv index a0155a90101..0e2d8348a65 100644 --- a/Lib9c/TableCSV/EnhancementCostSheetV3.csv +++ b/Lib9c/TableCSV/EnhancementCostSheetV3.csv @@ -1376,3 +1376,45 @@ id,item_sub_type,grade,level,cost,exp,required_block,base_stat_growth_min,base_s 600306,EquipmentMaterial,6,0,0,5000000,0,0,0,0,0,0,0,0,0 600307,EquipmentMaterial,7,0,0,50000000,0,0,0,0,0,0,0,0,0 600308,EquipmentMaterial,8,0,0,500000000,0,0,0,0,0,0,0,0,0 +1420,Aura,9,1,0,4940000,1,700,800,1250,1400,0,0,0,0 +1421,Aura,9,2,0,7020000,1,800,900,1400,1500,0,0,0,0 +1422,Aura,9,3,0,11180000,1,900,1000,900,1000,3600,3700,800,900 +1423,Aura,9,4,0,19500000,1,1000,1100,1000,1100,0,0,0,0 +1424,Aura,9,5,0,27820000,1,1500,1600,1500,1600,0,0,0,0 +1425,Aura,9,6,0,36140000,1,2000,2100,2000,2100,5000,5100,800,900 +1426,Aura,9,7,0,48620000,1,1800,1900,1800,1900,2000,2100,200,200 +1427,Aura,9,8,0,69420000,1,1600,1700,1600,1700,2000,2100,200,200 +1428,Aura,9,9,0,155890000,1,1400,1500,1400,1500,5000,5100,800,900 +1429,Aura,9,10,0,311970000,1,1200,1300,1200,1300,1500,1600,200,200 +1430,Aura,9,11,0,525590000,1,1000,1100,1000,1100,1500,1600,200,200 +1431,Aura,9,12,0,924470000,1,1000,1100,1000,1100,1500,1600,200,200 +1432,Aura,9,13,0,1414880000,1,1000,1100,1000,1100,1500,1600,200,200 +1433,Aura,9,14,0,1996800000,1,1000,1100,1000,1100,1500,1600,200,200 +1434,Aura,9,15,0,2396160000,1,1000,1100,1000,1100,1500,1600,200,200 +1435,Aura,9,16,0,2795520000,1,1000,1100,1000,1100,1500,1600,200,200 +1436,Aura,9,17,0,3194880000,1,1000,1100,1000,1100,1500,1600,200,200 +1437,Aura,9,18,0,3594240000,1,1000,1100,1000,1100,1500,1600,200,200 +1438,Aura,9,19,0,3993600000,1,1000,1100,1000,1100,1500,1600,200,200 +1439,Aura,9,20,0,4392960000,1,1000,1100,1000,1100,1500,1600,200,200 +1440,Aura,9,21,0,4792320000,1,1000,1100,1000,1100,1500,1600,200,200 +1441,Grimoire,9,1,0,4940000,1,2500,2600,4400,4500,5000,5100,0,0 +1442,Grimoire,9,2,0,7020000,1,2500,2600,4400,4500,1000,1100,0,0 +1443,Grimoire,9,3,0,11180000,1,2500,2600,2500,2600,1000,1100,800,900 +1444,Grimoire,9,4,0,19500000,1,1900,2000,1900,2000,2000,2100,0,0 +1445,Grimoire,9,5,0,27820000,1,1900,2000,1900,2000,2000,2100,0,0 +1446,Grimoire,9,6,0,36140000,1,1900,2000,1900,2000,3000,3100,800,900 +1447,Grimoire,9,7,0,48620000,1,1200,1300,1200,1300,2000,2000,200,200 +1448,Grimoire,9,8,0,69420000,1,1200,1300,1200,1300,2000,2000,200,200 +1449,Grimoire,9,9,0,155890000,1,1200,1300,1200,1300,5000,5100,800,900 +1450,Grimoire,9,10,0,311970000,1,1200,1300,1200,1300,1500,1600,200,200 +1451,Grimoire,9,11,0,525590000,1,1200,1300,1200,1300,1500,1600,200,200 +1452,Grimoire,9,12,0,924470000,1,1200,1300,1200,1300,1500,1600,200,200 +1453,Grimoire,9,13,0,1414880000,1,1200,1300,1200,1300,1500,1600,200,200 +1454,Grimoire,9,14,0,1996800000,1,1200,1300,1200,1300,1500,1600,200,200 +1455,Grimoire,9,15,0,2396160000,1,1200,1300,1200,1300,1500,1600,200,200 +1456,Grimoire,9,16,0,2795520000,1,1200,1300,1200,1300,1500,1600,200,200 +1457,Grimoire,9,17,0,3194880000,1,1200,1300,1200,1300,1500,1600,200,200 +1458,Grimoire,9,18,0,3594240000,1,1200,1300,1200,1300,1500,1600,200,200 +1459,Grimoire,9,19,0,3993600000,1,1200,1300,1200,1300,1500,1600,200,200 +1460,Grimoire,9,20,0,4392960000,1,1200,1300,1200,1300,1500,1600,200,200 +1461,Grimoire,9,21,0,4792320000,1,1200,1300,1200,1300,1500,1600,200,200 From dc260175d072efdcf38e0a680864e6ee629c8931 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:50 +0900 Subject: [PATCH 06/41] Update CrystalEquipmentGrindingSheet.csv from Google Sheets --- Lib9c/TableCSV/CrystalEquipmentGrindingSheet.csv | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Lib9c/TableCSV/CrystalEquipmentGrindingSheet.csv b/Lib9c/TableCSV/CrystalEquipmentGrindingSheet.csv index c09213c0e62..e85fe2b7d28 100644 --- a/Lib9c/TableCSV/CrystalEquipmentGrindingSheet.csv +++ b/Lib9c/TableCSV/CrystalEquipmentGrindingSheet.csv @@ -358,3 +358,16 @@ id,enchant_base_id,gain_crystal,reward_material1,count1,reward_material2,count2 20160005,20160000,1,600401,15,600402,30 20460005,20460000,1,600401,15,600402,30 20560005,20560000,1,600401,15,600402,30 +10690000,10100000,100000000,600203,2,600202,2 +10690001,10100000,100000000,600203,2,600202,2 +10690002,10100000,100000000,600203,2,600202,2 +10690003,10100000,100000000,600203,2,600202,2 +10790001,10100000,100000000,600203,2,600202,2 +10790002,10100000,100000000,600203,2,600202,2 +10790003,10100000,100000000,600203,2,600202,2 +10790004,10100000,100000000,600203,2,600202,2 +10790005,10100000,100000000,600203,2,600202,2 +10790006,10100000,100000000,600203,2,600202,2 +10790007,10100000,100000000,600203,2,600202,2 +10790008,10100000,100000000,600203,2,600202,2 +10790009,10100000,100000000,600203,2,600202,2 From cbbfe97f00b088afe2ca7b4c1a6aaf65bd43657f Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:50 +0900 Subject: [PATCH 07/41] Update SynthesizeSheet.csv from Google Sheets --- Lib9c/TableCSV/SynthesizeSheet.csv | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib9c/TableCSV/SynthesizeSheet.csv b/Lib9c/TableCSV/SynthesizeSheet.csv index dcae4fd0491..776b67d991a 100644 --- a/Lib9c/TableCSV/SynthesizeSheet.csv +++ b/Lib9c/TableCSV/SynthesizeSheet.csv @@ -17,6 +17,9 @@ _value of successful_rate is between 0 and 10000,,, 7,Aura,9,10000 7,Grimoire,9,10000 7,FullCostume,11,10000 -8,Aura,1,0 -8,Grimoire,1,0 -8,FullCostume,1,0 +8,Aura,4,10000 +8,Grimoire,4,10000 +8,FullCostume,4,10000 +9,Aura,1,0 +9,Grimoire,1,0 +9,FullCostume,1,0 From e68408d946d1022aae283611c23c2ccee66a7a89 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:51 +0900 Subject: [PATCH 08/41] Update SynthesizeWeightSheet.csv from Google Sheets --- Lib9c/TableCSV/SynthesizeWeightSheet.csv | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Lib9c/TableCSV/SynthesizeWeightSheet.csv b/Lib9c/TableCSV/SynthesizeWeightSheet.csv index 3cee0cc6e77..be0cb7b8827 100644 --- a/Lib9c/TableCSV/SynthesizeWeightSheet.csv +++ b/Lib9c/TableCSV/SynthesizeWeightSheet.csv @@ -117,3 +117,18 @@ _default value for weight is 0. list only items that must be synthesizable., 40100054,10000 40100060,10000 40100061,10000 +10690000,10000 +10690001,10000 +10690002,10000 +10690003,10000 +10790001,10000 +10790002,10000 +10790003,10000 +10790004,10000 +10790005,10000 +10790006,10000 +10790007,10000 +10790008,10000 +10790009,10000 +40100068,10000 +40100069,10000 From c8a1de0f893ee28de79f17f5a108d646cb438142 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:51 +0900 Subject: [PATCH 09/41] Update EquipmentSummonSheet.csv from Google Sheets --- Lib9c/TableCSV/EquipmentSummonSheet.csv | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib9c/TableCSV/EquipmentSummonSheet.csv b/Lib9c/TableCSV/EquipmentSummonSheet.csv index 42f6ea7b4bb..dc62798d3f2 100644 --- a/Lib9c/TableCSV/EquipmentSummonSheet.csv +++ b/Lib9c/TableCSV/EquipmentSummonSheet.csv @@ -1,5 +1,5 @@ -groupID,cost_material,cost_material_count,cost_ncg,GUARANTEE,min_grade_11,count_11,min_grade_110,count_110,recipe1ID,recipe1ratio,recipe2ID,recipe2ratio,recipe3ID,recipe3ratio,recipe4ID,recipe4ratio,recipe5ID,recipe5ratio,recipe6ID,recipe6ratio,recipe7ID,recipe7ratio,recipe8ID,recipe8ratio,recipe9ID,recipe9ratio,recipe10ID,recipe10ratio,recipe11ID,recipe11ratio,recipe12ID,recipe12ratio,recipe13ID,recipe13ratio,recipe14ID,recipe14ratio,recipe15ID,recipe15ratio,recipe16ID,recipe16ratio,recipe17ID,recipe17ratio,recipe18ID,recipe18ratio,recipe19ID,recipe19ratio,recipe20ID,recipe20ratio,recipe21ID,recipe21ratio,recipe22ID,recipe22ratio,recipe23ID,recipe23ratio,recipe24ID,recipe24ratio,recipe25ID,recipe25ratio,recipe26ID,recipe26ratio,recipe27ID,recipe27ratio,recipe28ID,recipe28ratio,recipe29ID,recipe29ratio,recipe30ID,recipe30ratio,recipe31ID,recipe31ratio,recipe32ID,recipe32ratio,recipe33ID,recipe33ratio,recipe34ID,recipe34ratio,recipe35ID,recipe35ratio,recipe36ID,recipe36ratio,recipe37ID,recipe37ratio,recipe38ID,recipe38ratio,recipe39ID,recipe39ratio,recipe40ID,recipe40ratio,recipe41ID,recipe41ratio,recipe42ID,recipe42ratio,recipe43ID,recipe43ratio,recipe44ID,recipe44ratio,recipe45ID,recipe45ratio -10001,800201,10,0,GUARANTEE,3,1,3,5,173,100,172,3500,171,6000,176,3,181,3,186,3,175,30,180,30,185,30,174,100,179,100,184,100,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, -10002,600201,20,0,GUARANTEE,4,4,5,8,216,25,215,1000,214,5000,178,150,183,100,188,100,177,1000,182,1000,187,1000,176,5000,181,5000,186,5000,175,10000,180,10000,185,10000,174,15000,179,15000,184,15000,253,25,254,25,255,25,283,5,284,5,285,5,286,5,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, -30001,600202,20,0,GUARANTEE,5,3,6,2,216,25,215,50,214,500,177,50,182,50,187,50,176,500,181,500,186,500,175,2560,180,2560,185,2560,253,25,254,25,255,25,283,5,284,5,285,5,286,5,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, -40001,600203,20,0,GUARANTEE,5,5,6,4,217,900,218,900,219,900,220,900,221,900,222,900,223,900,224,900,225,900,226,450,227,450,228,450,229,450,230,450,231,450,232,450,233,450,234,450,235,150,236,150,237,150,238,150,239,150,240,150,241,150,242,150,243,150,244,15,245,15,246,15,247,15,248,15,249,15,250,15,251,15,252,15,287,3,288,3,289,3,290,3,291,3,292,3,293,3,294,3,295,3 \ No newline at end of file +groupID,cost_material,cost_material_count,cost_ncg,GUARANTEE,min_grade_11,count_11,min_grade_110,count_110,recipe1ID,recipe1ratio,recipe2ID,recipe2ratio,recipe3ID,recipe3ratio,recipe4ID,recipe4ratio,recipe5ID,recipe5ratio,recipe6ID,recipe6ratio,recipe7ID,recipe7ratio,recipe8ID,recipe8ratio,recipe9ID,recipe9ratio,recipe10ID,recipe10ratio,recipe11ID,recipe11ratio,recipe12ID,recipe12ratio,recipe13ID,recipe13ratio,recipe14ID,recipe14ratio,recipe15ID,recipe15ratio,recipe16ID,recipe16ratio,recipe17ID,recipe17ratio,recipe18ID,recipe18ratio,recipe19ID,recipe19ratio,recipe20ID,recipe20ratio,recipe21ID,recipe21ratio,recipe22ID,recipe22ratio,recipe23ID,recipe23ratio,recipe24ID,recipe24ratio,recipe25ID,recipe25ratio,recipe26ID,recipe26ratio,recipe27ID,recipe27ratio,recipe28ID,recipe28ratio,recipe29ID,recipe29ratio,recipe30ID,recipe30ratio,recipe31ID,recipe31ratio,recipe32ID,recipe32ratio,recipe33ID,recipe33ratio,recipe34ID,recipe34ratio,recipe35ID,recipe35ratio,recipe36ID,recipe36ratio,recipe37ID,recipe37ratio,recipe38ID,recipe38ratio,recipe39ID,recipe39ratio,recipe40ID,recipe40ratio,recipe41ID,recipe41ratio,recipe42ID,recipe42ratio,recipe43ID,recipe43ratio,recipe44ID,recipe44ratio,recipe45ID,recipe45ratio,recipe46ID,recipe46ratio,recipe47ID,recipe47ratio,recipe48ID,recipe48ratio,recipe49ID,recipe49ratio,recipe50ID,recipe50ratio,recipe51ID,recipe51ratio,recipe52ID,recipe52ratio,recipe53ID,recipe53ratio,recipe54ID,recipe54ratio,recipe55ID,recipe55ratio,recipe56ID,recipe56ratio,recipe57ID,recipe57ratio,recipe58ID,recipe58ratio,recipe59ID,recipe59ratio,recipe60ID,recipe60ratio,recipe61ID,recipe61ratio,recipe62ID,recipe62ratio,recipe63ID,recipe63ratio,recipe64ID,recipe64ratio,recipe65ID,recipe65ratio,recipe66ID,recipe66ratio,recipe67ID,recipe67ratio,recipe68ID,recipe68ratio,recipe69ID,recipe69ratio,recipe70ID,recipe70ratio +10001,800201,10,0,GUARANTEE,3,1,4,2,173,100,172,3500,171,6000,176,3,181,3,186,3,175,30,180,30,185,30,174,100,179,100,184,100,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, +10002,600201,20,0,GUARANTEE,5,2,6,3,216,25,215,1000,214,5000,178,150,183,100,188,100,177,1000,182,1000,187,1000,176,5000,181,5000,186,5000,175,10000,180,10000,185,10000,174,15000,179,15000,184,15000,253,25,254,25,255,25,283,5,284,5,285,5,286,5,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, +30001,600202,20,0,GUARANTEE,6,1,7,1,216,250,215,500,214,5000,177,500,182,500,187,500,176,5000,181,5000,186,5000,175,25600,180,25600,185,25600,253,250,254,250,255,250,283,50,284,50,285,50,286,50,298,10,299,10,300,10,301,10,336,2,337,2,338,2,339,2,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, +40001,600203,20,0,GUARANTEE,6,2,7,2,217,9000,218,9000,219,9000,220,9000,221,9000,222,9000,223,9000,224,9000,225,9000,226,4500,227,4500,228,4500,229,4500,230,4500,231,4500,232,4500,233,4500,234,4500,235,1500,236,1500,237,1500,238,1500,239,1500,240,1500,241,1500,242,1500,243,1500,244,250,245,250,246,250,247,250,248,250,249,250,250,250,251,250,252,250,287,50,288,50,289,50,290,50,291,50,292,50,293,50,294,50,295,50,302,10,303,10,304,10,305,10,306,10,307,10,308,10,309,10,310,10,340,2,341,2,342,2,343,2,344,2,345,2,346,2,347,2,348,2,,,,,,,,,,,,,, \ No newline at end of file From 32a8a4421c396e007051ec99a0f63e1c2063ac8c Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:51 +0900 Subject: [PATCH 10/41] Update CostumeSummonSheet.csv from Google Sheets --- Lib9c/TableCSV/CostumeSummonSheet.csv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib9c/TableCSV/CostumeSummonSheet.csv b/Lib9c/TableCSV/CostumeSummonSheet.csv index 97933e03d59..9f6170692ca 100644 --- a/Lib9c/TableCSV/CostumeSummonSheet.csv +++ b/Lib9c/TableCSV/CostumeSummonSheet.csv @@ -1,3 +1,3 @@ -groupID,cost_material,cost_material_count,cost_ncg,GUARANTEE,min_grade_11,count_11,min_grade_110,count_110,recipe1ID,recipe1ratio,recipe2ID,recipe2ratio,recipe3ID,recipe3ratio,recipe4ID,recipe4ratio,recipe5ID,recipe5ratio,recipe6ID,recipe6ratio,recipe7ID,recipe7ratio,recipe8ID,recipe8ratio,recipe9ID,recipe9ratio,recipe10ID,recipe10ratio,recipe11ID,recipe11ratio,recipe12ID,recipe12ratio,recipe13ID,recipe13ratio,recipe14ID,recipe14ratio,recipe15ID,recipe15ratio,recipe16ID,recipe16ratio,recipe17ID,recipe17ratio,recipe18ID,recipe18ratio,recipe19ID,recipe19ratio,recipe20ID,recipe20ratio,recipe21ID,recipe21ratio,recipe22ID,recipe22ratio,recipe23ID,recipe23ratio,recipe24ID,recipe24ratio,recipe25ID,recipe25ratio,recipe26ID,recipe26ratio,recipe27ID,recipe27ratio,recipe28ID,recipe28ratio,recipe29ID,recipe29ratio,recipe30ID,recipe30ratio,recipe31ID,recipe31ratio,recipe32ID,recipe32ratio,recipe33ID,recipe33ratio -50001,600202,20,0,GUARANTEE,5,3,6,3,40100013,175000,40100014,175000,40100015,175000,40100016,175000,40100025,45000,40100028,45000,40100017,45000,40100024,45000,40100029,45000,40100036,45000,40100012,3300,40100018,3300,40100020,3300,40100021,3300,40100022,3300,40100023,3300,40100026,3300,40100027,3300,40100030,3300,40100033,100,40100035,100,40100040,100,40100031,100,40100039,100,40100044,20,40100046,20,40100047,20,40100045,20,40100052,20,40100053,20,40100054,20,40100060,1,40100061,1 -50002,600203,20,0,GUARANTEE,6,1,7,1,40100013,11250,40100014,11250,40100015,11250,40100016,11250,40100025,7000,40100028,7000,40100017,7000,40100024,7000,40100029,7000,40100036,7000,40100012,1410,40100018,1410,40100020,1410,40100021,1410,40100022,1410,40100023,1410,40100026,1410,40100027,1410,40100030,1410,40100033,100,40100035,100,40100040,100,40100031,100,40100039,100,40100044,30,40100046,30,40100047,30,40100045,30,40100052,30,40100053,30,40100054,30,40100060,10,40100061,10 +groupID,cost_material,cost_material_count,cost_ncg,GUARANTEE,min_grade_11,count_11,min_grade_110,count_110,recipe1ID,recipe1ratio,recipe2ID,recipe2ratio,recipe3ID,recipe3ratio,recipe4ID,recipe4ratio,recipe5ID,recipe5ratio,recipe6ID,recipe6ratio,recipe7ID,recipe7ratio,recipe8ID,recipe8ratio,recipe9ID,recipe9ratio,recipe10ID,recipe10ratio,recipe11ID,recipe11ratio,recipe12ID,recipe12ratio,recipe13ID,recipe13ratio,recipe14ID,recipe14ratio,recipe15ID,recipe15ratio,recipe16ID,recipe16ratio,recipe17ID,recipe17ratio,recipe18ID,recipe18ratio,recipe19ID,recipe19ratio,recipe20ID,recipe20ratio,recipe21ID,recipe21ratio,recipe22ID,recipe22ratio,recipe23ID,recipe23ratio,recipe24ID,recipe24ratio,recipe25ID,recipe25ratio,recipe26ID,recipe26ratio,recipe27ID,recipe27ratio,recipe28ID,recipe28ratio,recipe29ID,recipe29ratio,recipe30ID,recipe30ratio,recipe31ID,recipe31ratio,recipe32ID,recipe32ratio,recipe33ID,recipe33ratio,recipe34ID,recipe34ratio,recipe35ID,recipe35ratio +50001,600202,20,0,GUARANTEE,5,3,6,3,40100013,175000,40100014,175000,40100015,175000,40100016,175000,40100025,45000,40100028,45000,40100017,45000,40100024,45000,40100029,45000,40100036,45000,40100012,3300,40100018,3300,40100020,3300,40100021,3300,40100022,3300,40100023,3300,40100026,3300,40100027,3300,40100030,3300,40100033,100,40100035,100,40100040,100,40100031,100,40100039,100,40100044,20,40100046,20,40100047,20,40100045,20,40100052,20,40100053,20,40100054,20,40100060,1,40100061,1,,,, +50002,600203,20,0,GUARANTEE,6,1,7,1,40100013,11250,40100014,11250,40100015,11250,40100016,11250,40100025,7000,40100028,7000,40100017,7000,40100024,7000,40100029,7000,40100036,7000,40100012,1410,40100018,1410,40100020,1410,40100021,1410,40100022,1410,40100023,1410,40100026,1410,40100027,1410,40100030,1410,40100033,100,40100035,100,40100040,100,40100031,100,40100039,100,40100044,30,40100046,30,40100047,30,40100045,30,40100052,30,40100053,30,40100054,30,40100060,10,40100061,10,40100068,1,40100069,1 From 1e015123b7c739c5bd763a30f8b46ce879a7a398 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:51 +0900 Subject: [PATCH 11/41] Update RuneListSheet.csv from Google Sheets --- Lib9c/TableCSV/RuneListSheet.csv | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib9c/TableCSV/RuneListSheet.csv b/Lib9c/TableCSV/RuneListSheet.csv index c7e3fe24439..77516c673b6 100644 --- a/Lib9c/TableCSV/RuneListSheet.csv +++ b/Lib9c/TableCSV/RuneListSheet.csv @@ -50,4 +50,8 @@ id,_name,grade,rune_type,required_level,use_place,bonus_coef 10054,Breaker Rune,8,2,1,7,200 20003,Golden Apple Rune,8,2,1,7,200 10055,Sight,8,2,1,7,200 -10056,Bifrost,8,1,1,7,200 \ No newline at end of file +10056,Bifrost,8,1,1,7,200 +10057,Gungnir,9,1,1,7,500 +10058,Brisingamen,9,1,1,7,500 +10059,Sleipnir,9,2,1,7,500 +10060,Draupnir,9,2,1,7,500 \ No newline at end of file From 6386eb05ed5d4781834cdd8aeb29208ec8d467fd Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:51 +0900 Subject: [PATCH 12/41] Update RuneSheet.csv from Google Sheets --- Lib9c/TableCSV/RuneSheet.csv | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Lib9c/TableCSV/RuneSheet.csv b/Lib9c/TableCSV/RuneSheet.csv index 696e4cdd7e2..e2bfecca8ff 100644 --- a/Lib9c/TableCSV/RuneSheet.csv +++ b/Lib9c/TableCSV/RuneSheet.csv @@ -48,6 +48,10 @@ c,_name,ticker 10052,Runestone of Ragnarok,RUNESTONE_RAGNAROK 10053,Freya PETPOP Rune,RUNESTONE_FREYA_PETPOP 10054,Breaker Rune,RUNESTONE_BREAKER -20003,Golden Apple Rune,RUNESTONE_GOLDENAPPLE +20003,Golden Apple Rune,RUNESTONE_GOLDENFRUIT 10055,Sight,RUNESTONE_SIGHT -10056,Bifrost,RUNESTONE_BIFROST \ No newline at end of file +10056,Bifrost,RUNESTONE_BIFROST +10057,Gungnir,RUNESTONE_GUNGNIR +10058,Brisingamen,RUNESTONE_BRISINGAMEN +10059,Sleipnir,RUNESTONE_SLEIPNIR +10060,Draupnir,RUNESTONE_DRAUPNIR \ No newline at end of file From 03e21bbabbe25b8cbf19aa4ac8ca34eed5728a1e Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:52 +0900 Subject: [PATCH 13/41] Update RuneOptionSheet.csv from Google Sheets --- Lib9c/TableCSV/RuneOptionSheet.csv | 102 ++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/Lib9c/TableCSV/RuneOptionSheet.csv b/Lib9c/TableCSV/RuneOptionSheet.csv index 4514511e506..de61768129f 100644 --- a/Lib9c/TableCSV/RuneOptionSheet.csv +++ b/Lib9c/TableCSV/RuneOptionSheet.csv @@ -12648,4 +12648,104 @@ rune_id,level,total_cp,stat_type_1,value_1,value_type_1,stat_type_2,value_2,valu 20003,47,44481066,HP,6001020,Add,ATK,2480918,Add,DEF,1355306,Add,700051,12,44,2378,Add,NONE,Caster,10 20003,48,44943211,HP,6067347,Add,ATK,2505612,Add,DEF,1370204,Add,700051,12,44,2418,Add,NONE,Caster,10 20003,49,45405355,HP,6133673,Add,ATK,2530306,Add,DEF,1385102,Add,700051,12,45,2459,Add,NONE,Caster,10 -20003,50,45867500,HP,6200000,Add,ATK,2555000,Add,DEF,1400000,Add,700051,12,45,2500,Add,NONE,Caster,10 \ No newline at end of file +20003,50,45867500,HP,6200000,Add,ATK,2555000,Add,DEF,1400000,Add,700051,12,45,2500,Add,NONE,Caster,10 +10057,1,50375660,ATK,1599227,Add,HIT,7300821,Add,SPD,5597296,Add,,,,,,,, +10057,2,52354166,ATK,1662037,Add,HIT,7587560,Add,SPD,5817130,Add,,,,,,,, +10057,3,54332672,ATK,1724847,Add,HIT,7874300,Add,SPD,6036963,Add,,,,,,,, +10057,4,56311169,ATK,1787656,Add,HIT,8161039,Add,SPD,6256797,Add,,,,,,,, +10057,5,58289672,ATK,1850466,Add,HIT,8447778,Add,SPD,6476630,Add,,,,,,,, +10057,6,60268171,ATK,1913275,Add,HIT,8734518,Add,SPD,6696464,Add,,,,,,,, +10057,7,62246675,ATK,1976085,Add,HIT,9021257,Add,SPD,6916297,Add,,,,,,,, +10057,8,64225171,ATK,2038894,Add,HIT,9307996,Add,SPD,7136131,Add,,,,,,,, +10057,9,66203677,ATK,2101704,Add,HIT,9594736,Add,SPD,7355964,Add,,,,,,,, +10057,10,68182184,ATK,2164514,Add,HIT,9881475,Add,SPD,7575798,Add,,,,,,,, +10057,11,70160679,ATK,2227323,Add,HIT,10168215,Add,SPD,7795631,Add,,,,,,,, +10057,12,72139186,ATK,2290133,Add,HIT,10454954,Add,SPD,8015465,Add,,,,,,,, +10057,13,74117679,ATK,2352942,Add,HIT,10741693,Add,SPD,8235298,Add,,,,,,,, +10057,14,76096188,ATK,2415752,Add,HIT,11028433,Add,SPD,8455132,Add,,,,,,,, +10057,15,78074692,ATK,2478562,Add,HIT,11315172,Add,SPD,8674965,Add,,,,,,,, +10057,16,80053188,ATK,2541371,Add,HIT,11601911,Add,SPD,8894799,Add,,,,,,,, +10057,17,82031694,ATK,2604181,Add,HIT,11888651,Add,SPD,9114632,Add,,,,,,,, +10057,18,84010190,ATK,2666990,Add,HIT,12175390,Add,SPD,9334466,Add,,,,,,,, +10057,19,85988696,ATK,2729800,Add,HIT,12462130,Add,SPD,9554299,Add,,,,,,,, +10057,20,87967192,ATK,2792609,Add,HIT,12748869,Add,SPD,9774133,Add,,,,,,,, +10057,21,89945696,ATK,2855419,Add,HIT,13035608,Add,SPD,9993966,Add,,,,,,,, +10057,22,91924205,ATK,2918229,Add,HIT,13322348,Add,SPD,10213800,Add,,,,,,,, +10057,23,93902698,ATK,2981038,Add,HIT,13609087,Add,SPD,10433633,Add,,,,,,,, +10057,24,95881205,ATK,3043848,Add,HIT,13895826,Add,SPD,10653467,Add,,,,,,,, +10057,25,97859700,ATK,3106657,Add,HIT,14182566,Add,SPD,10873300,Add,,,,,,,, +10058,1,48221171,HP,22962462,Add,DEF,1530831,Add,HIT,6988575,Add,,,,,,,, +10058,2,50115052,HP,23864311,Add,DEF,1590954,Add,HIT,7263051,Add,,,,,,,, +10058,3,52008933,HP,24766161,Add,DEF,1651077,Add,HIT,7537527,Add,,,,,,,, +10058,4,53902824,HP,25668010,Add,DEF,1711201,Add,HIT,7812003,Add,,,,,,,, +10058,5,55796706,HP,26569860,Add,DEF,1771324,Add,HIT,8086479,Add,,,,,,,, +10058,6,57690586,HP,27471709,Add,DEF,1831447,Add,HIT,8360955,Add,,,,,,,, +10058,7,59584478,HP,28373559,Add,DEF,1891571,Add,HIT,8635431,Add,,,,,,,, +10058,8,61478359,HP,29275408,Add,DEF,1951694,Add,HIT,8909907,Add,,,,,,,, +10058,9,63372240,HP,30177258,Add,DEF,2011817,Add,HIT,9184383,Add,,,,,,,, +10058,10,65266121,HP,31079107,Add,DEF,2071940,Add,HIT,9458859,Add,,,,,,,, +10058,11,67160012,HP,31980957,Add,DEF,2132064,Add,HIT,9733335,Add,,,,,,,, +10058,12,69053893,HP,32882806,Add,DEF,2192187,Add,HIT,10007811,Add,,,,,,,, +10058,13,70947774,HP,33784656,Add,DEF,2252310,Add,HIT,10282287,Add,,,,,,,, +10058,14,72841665,HP,34686505,Add,DEF,2312434,Add,HIT,10556763,Add,,,,,,,, +10058,15,74735544,HP,35588355,Add,DEF,2372557,Add,HIT,10831238,Add,,,,,,,, +10058,16,76629425,HP,36490204,Add,DEF,2432680,Add,HIT,11105714,Add,,,,,,,, +10058,17,78523317,HP,37392054,Add,DEF,2492804,Add,HIT,11380190,Add,,,,,,,, +10058,18,80417197,HP,38293903,Add,DEF,2552927,Add,HIT,11654666,Add,,,,,,,, +10058,19,82311079,HP,39195753,Add,DEF,2613050,Add,HIT,11929142,Add,,,,,,,, +10058,20,84204970,HP,40097603,Add,DEF,2673174,Add,HIT,12203618,Add,,,,,,,, +10058,21,86098851,HP,40999452,Add,DEF,2733297,Add,HIT,12478094,Add,,,,,,,, +10058,22,87992732,HP,41901302,Add,DEF,2793420,Add,HIT,12752570,Add,,,,,,,, +10058,23,89886613,HP,42803151,Add,DEF,2853543,Add,HIT,13027046,Add,,,,,,,, +10058,24,91780505,HP,43705001,Add,DEF,2913667,Add,HIT,13301522,Add,,,,,,,, +10058,25,93674385,HP,44606850,Add,DEF,2973790,Add,HIT,13575998,Add,,,,,,,, +10059,1,46032564,SPD,7672094,Add,HIT,10007079,Add,,,,700015,20,30,-40,Add,NONE,Caster,8 +10059,2,47840489,SPD,7973415,Add,HIT,10400106,Add,,,,700015,20,31,-41,Add,NONE,Caster,8 +10059,3,49648416,SPD,8274736,Add,HIT,10793134,Add,,,,700015,20,31,-41,Add,NONE,Caster,8 +10059,4,51456341,SPD,8576057,Add,HIT,11186161,Add,,,,700015,20,32,-42,Add,NONE,Caster,8 +10059,5,53264269,SPD,8877378,Add,HIT,11579189,Add,,,,700015,20,32,-42,Add,NONE,Caster,8 +10059,6,55072194,SPD,9178699,Add,HIT,11972216,Add,,,,700015,20,33,-43,Add,NONE,Caster,8 +10059,7,56880121,SPD,9480020,Add,HIT,12365244,Add,,,,700015,19,34,-44,Add,NONE,Caster,8 +10059,8,58688046,SPD,9781341,Add,HIT,12758271,Add,,,,700015,19,34,-44,Add,NONE,Caster,8 +10059,9,60495974,SPD,10082662,Add,HIT,13151299,Add,,,,700015,19,35,-45,Add,NONE,Caster,8 +10059,10,62303899,SPD,10383983,Add,HIT,13544326,Add,,,,700015,19,36,-46,Add,NONE,Caster,8 +10059,11,64111826,SPD,10685304,Add,HIT,13937354,Add,,,,700015,18,36,-46,Add,NONE,Caster,8 +10059,12,65919751,SPD,10986625,Add,HIT,14330381,Add,,,,700015,18,37,-47,Add,NONE,Caster,8 +10059,13,67727682,SPD,11287947,Add,HIT,14723409,Add,,,,700015,18,38,-48,Add,NONE,Caster,8 +10059,14,69535607,SPD,11589268,Add,HIT,15116436,Add,,,,700015,18,38,-48,Add,NONE,Caster,8 +10059,15,71343534,SPD,11890589,Add,HIT,15509464,Add,,,,700015,17,39,-49,Add,NONE,Caster,8 +10059,16,73151459,SPD,12191910,Add,HIT,15902491,Add,,,,700015,17,39,-49,Add,NONE,Caster,8 +10059,17,74959387,SPD,12493231,Add,HIT,16295519,Add,,,,700015,17,40,-50,Add,NONE,Caster,8 +10059,18,76767312,SPD,12794552,Add,HIT,16688546,Add,,,,700015,17,41,-51,Add,NONE,Caster,8 +10059,19,78575239,SPD,13095873,Add,HIT,17081574,Add,,,,700015,16,41,-51,Add,NONE,Caster,8 +10059,20,80383164,SPD,13397194,Add,HIT,17474601,Add,,,,700015,16,42,-52,Add,NONE,Caster,8 +10059,21,82191092,SPD,13698515,Add,HIT,17867629,Add,,,,700015,16,42,-52,Add,NONE,Caster,8 +10059,22,83999017,SPD,13999836,Add,HIT,18260656,Add,,,,700015,16,43,-53,Add,NONE,Caster,8 +10059,23,85806944,SPD,14301157,Add,HIT,18653684,Add,,,,700015,15,44,-54,Add,NONE,Caster,8 +10059,24,87614872,SPD,14602479,Add,HIT,19046711,Add,,,,700015,15,44,-54,Add,NONE,Caster,8 +10059,25,89422800,SPD,14903800,Add,HIT,19439739,Add,,,,700015,15,45,-55,Add,NONE,Caster,8 +10060,1,40552117,ATK,1931053,Add,HP,28965801,Add,,,,700004,16,30,2,Add,NONE,Caster,5 +10060,2,42144800,ATK,2006895,Add,HP,30103432,Add,,,,700004,16,31,2,Add,NONE,Caster,5 +10060,3,43737482,ATK,2082737,Add,HP,31241062,Add,,,,700004,16,32,2,Add,NONE,Caster,5 +10060,4,45330164,ATK,2158579,Add,HP,32378692,Add,,,,700004,16,33,2,Add,NONE,Caster,5 +10060,5,46922857,ATK,2234422,Add,HP,33516323,Add,,,,700004,15,34,2,Add,NONE,Caster,5 +10060,6,48515539,ATK,2310264,Add,HP,34653953,Add,,,,700004,15,35,2,Add,NONE,Caster,5 +10060,7,50108222,ATK,2386106,Add,HP,35791584,Add,,,,700004,15,36,2,Add,NONE,Caster,5 +10060,8,51700904,ATK,2461948,Add,HP,36929214,Add,,,,700004,15,37,2,Add,NONE,Caster,5 +10060,9,53293586,ATK,2537790,Add,HP,38066844,Add,,,,700004,13,38,2,Add,NONE,Caster,6 +10060,10,54886268,ATK,2613632,Add,HP,39204475,Add,,,,700004,13,39,2,Add,NONE,Caster,6 +10060,11,56478950,ATK,2689474,Add,HP,40342105,Add,,,,700004,13,40,2,Add,NONE,Caster,6 +10060,12,58071633,ATK,2765316,Add,HP,41479736,Add,,,,700004,13,41,2,Add,NONE,Caster,6 +10060,13,59664315,ATK,2841158,Add,HP,42617366,Add,,,,700004,12,43,2,Add,NONE,Caster,6 +10060,14,61256997,ATK,2917000,Add,HP,43754996,Add,,,,700004,12,44,2,Add,NONE,Caster,6 +10060,15,62849680,ATK,2992842,Add,HP,44892627,Add,,,,700004,12,45,2,Add,NONE,Caster,6 +10060,16,64442362,ATK,3068684,Add,HP,46030257,Add,,,,700004,12,46,2,Add,NONE,Caster,6 +10060,17,66035044,ATK,3144526,Add,HP,47167887,Add,,,,700004,11,47,2,Add,NONE,Caster,6 +10060,18,67627727,ATK,3220368,Add,HP,48305518,Add,,,,700004,11,48,2,Add,NONE,Caster,6 +10060,19,69220409,ATK,3296210,Add,HP,49443148,Add,,,,700004,11,49,2,Add,NONE,Caster,6 +10060,20,70813091,ATK,3372052,Add,HP,50580779,Add,,,,700004,11,50,2,Add,NONE,Caster,6 +10060,21,72405773,ATK,3447894,Add,HP,51718409,Add,,,,700004,10,51,2,Add,NONE,Caster,6 +10060,22,73998455,ATK,3523736,Add,HP,52856039,Add,,,,700004,10,52,2,Add,NONE,Caster,6 +10060,23,75591138,ATK,3599578,Add,HP,53993670,Add,,,,700004,10,53,2,Add,NONE,Caster,6 +10060,24,77183820,ATK,3675420,Add,HP,55131300,Add,,,,700004,10,54,2,Add,NONE,Caster,6 +10060,25,78776503,ATK,3751262,Add,HP,56268931,Add,,,,700004,10,55,2,Add,NONE,Caster,6 \ No newline at end of file From aa977846f9cd3b68382d002a99d160482b4b0f5b Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:52 +0900 Subject: [PATCH 14/41] Update RuneCostSheet.csv from Google Sheets --- Lib9c/TableCSV/RuneCostSheet.csv | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Lib9c/TableCSV/RuneCostSheet.csv b/Lib9c/TableCSV/RuneCostSheet.csv index 95efaa8a488..d5876898a37 100644 --- a/Lib9c/TableCSV/RuneCostSheet.csv +++ b/Lib9c/TableCSV/RuneCostSheet.csv @@ -4033,4 +4033,16 @@ id,level_start,level_end,rune_stone_quantity,crystal_quantity,ncg_quantity,level 10055,12,50,20,10000000,0,10000 10056,1,10,10,1000000,0,10000 10056,11,11,20,0,300,10000 -10056,12,50,20,10000000,0,10000 \ No newline at end of file +10056,12,50,20,10000000,0,10000 +10057,1,10,20,4000000,0,10000 +10057,11,11,40,0,600,10000 +10057,12,25,40,40000000,0,10000 +10058,1,10,20,4000000,0,10000 +10058,11,11,40,0,600,10000 +10058,12,25,40,40000000,0,10000 +10059,1,10,40,4000000,0,10000 +10059,11,11,80,0,600,10000 +10059,12,25,80,40000000,0,10000 +10060,1,10,40,4000000,0,10000 +10060,11,11,80,0,600,10000 +10060,12,25,80,40000000,0,10000 \ No newline at end of file From ce202387897074b019efb7f74ee8cccb3267cb03 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:52 +0900 Subject: [PATCH 15/41] Update RuneSummonSheet.csv from Google Sheets --- Lib9c/TableCSV/RuneSummonSheet.csv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib9c/TableCSV/RuneSummonSheet.csv b/Lib9c/TableCSV/RuneSummonSheet.csv index abdc94d2613..010626caf2c 100644 --- a/Lib9c/TableCSV/RuneSummonSheet.csv +++ b/Lib9c/TableCSV/RuneSummonSheet.csv @@ -1,4 +1,4 @@ groupID,cost_material,cost_material_count,cost_ncg,GUARANTEE,min_grade_11,count_11,min_grade_110,count_110,recipe1ID,recipe1ratio,recipe2ID,recipe2ratio,recipe3ID,recipe3ratio,recipe4ID,recipe4ratio,recipe5ID,recipe5ratio,recipe6ID,recipe6ratio,recipe7ID,recipe7ratio,recipe8ID,recipe8ratio,recipe9ID,recipe9ratio,recipe10ID,recipe10ratio,recipe11ID,recipe11ratio,recipe12ID,recipe12ratio,recipe13ID,recipe13ratio,recipe14ID,recipe14ratio,recipe15ID,recipe15ratio,recipe16ID,recipe16ratio,recipe17ID,recipe17ratio,recipe18ID,recipe18ratio,recipe19ID,recipe19ratio,recipe20ID,recipe20ratio,recipe21ID,recipe21ratio,recipe22ID,recipe22ratio,recipe23ID,recipe23ratio,recipe24ID,recipe24ratio,recipe25ID,recipe25ratio,recipe26ID,recipe26ratio,recipe27ID,recipe27ratio,recipe28ID,recipe28ratio,recipe29ID,recipe29ratio,recipe30ID,recipe30ratio,recipe31ID,recipe31ratio,recipe32ID,recipe32ratio,recipe33ID,recipe33ratio,recipe34ID,recipe34ratio,recipe35ID,recipe35ratio,recipe36ID,recipe36ratio 20001,600201,20,0,GUARANTEE,5,1,6,1,10031,25,10032,25,10029,25,10030,25,10023,200,10024,200,10021,200,10022,200,10027,400,10028,400,10025,400,10026,400,10003,800,10013,800,10002,1000,10012,1000,10001,1900,10011,1900,10039,25,10040,25,10041,25,10042,25,,,,,,,,,,,,,,,,,,,,,,,,,,,, -30002,600202,20,0,GUARANTEE,6,1,7,1,10027,600,10028,600,10025,600,10026,600,10023,240,10024,240,10021,240,10022,240,10029,60,10030,60,10031,60,10032,60,10039,60,10040,60,10041,60,10042,60,10033,120,10034,120,10035,120,10043,10,10044,10,10045,10,10046,10,10051,2,10052,2,10055,2,10056,2,,,,,,,,,,,,,,,,,, -40002,600203,20,0,GUARANTEE,6,3,7,4,10027,480,10028,480,10025,480,10026,480,10023,240,10024,240,10021,240,10022,240,10029,60,10030,60,10031,60,10032,60,10039,60,10040,60,10041,60,10042,60,10033,120,10034,120,10035,120,10043,20,10044,20,10045,20,10046,20,10051,5,10052,5,10055,5,10056,5,,,,,,,,,,,,,,,,,, +30002,600202,20,0,GUARANTEE,6,1,7,1,10027,6000,10028,6000,10025,6000,10026,6000,10023,2400,10024,2400,10021,2400,10022,2400,10029,600,10030,600,10031,600,10032,600,10039,600,10040,600,10041,600,10042,600,10033,1200,10034,1200,10035,1200,10043,100,10044,100,10045,100,10046,100,10051,20,10052,20,10055,20,10056,20,10057,5,10058,5,10059,5,10060,5,,,,,,,,,, +40002,600203,20,0,GUARANTEE,6,3,7,4,10027,4800,10028,4800,10025,4800,10026,4800,10023,2400,10024,2400,10021,2400,10022,2400,10029,600,10030,600,10031,600,10032,600,10039,600,10040,600,10041,600,10042,600,10033,1200,10034,1200,10035,1200,10043,200,10044,200,10045,200,10046,200,10051,50,10052,50,10055,50,10056,50,10057,12,10058,12,10059,12,10060,12,,,,,,,,,, From 8875d094a489b06f9ec6fef7531c47d602ede001 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:52 +0900 Subject: [PATCH 16/41] Update CostumeItemSheet.csv from Google Sheets --- Lib9c/TableCSV/CostumeItemSheet.csv | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Lib9c/TableCSV/CostumeItemSheet.csv b/Lib9c/TableCSV/CostumeItemSheet.csv index f1668f260e4..529c78c314d 100644 --- a/Lib9c/TableCSV/CostumeItemSheet.csv +++ b/Lib9c/TableCSV/CostumeItemSheet.csv @@ -170,11 +170,13 @@ _spine_resource_path,`Title`: ``,,,, 49900058,June 26 Season Winner,Title,7,Normal, 40100060,Heimdall 26 2nd Champion,FullCostume,8,Normal, 40100061,Odin 26 2nd Champion,FullCostume,8,Normal, -49900059,July 26 Seasen Winner,Title,7,Normal, +49900059,July 26 Season Winner,Title,7,Normal, 40100062,Odin 26 3rd Champion,FullCostume,8,Normal, 40100063,Heimdall 26 3rd Champion,FullCostume,8,Normal, 40100064,TBD,FullCostume,8,Normal, 40100065,Ivaldi's Automaton,FullCostume,8,Normal, -49900060,August 26 Seasen Winner,Title,7,Normal, -49900061,September 26 Seasen Winner,Title,7,Normal, -40100066,TBD,FullCostume,8,Normal, \ No newline at end of file +49900060,August 26 Season Winner,Title,7,Normal, +49900061,September 26 Season Winner,Title,7,Normal, +40100066,TBD,FullCostume,8,Normal, +40100068,TBD (Ultimate Costume A),FullCostume,9,Normal, +40100069,TBD (Ultimate Costume B),FullCostume,9,Normal, \ No newline at end of file From 90422f9486a7f12b7ff2b87b5cadfb72392f4919 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:52 +0900 Subject: [PATCH 17/41] Update CostumeStatSheet.csv from Google Sheets --- Lib9c/TableCSV/CostumeStatSheet.csv | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib9c/TableCSV/CostumeStatSheet.csv b/Lib9c/TableCSV/CostumeStatSheet.csv index d0ae45abe75..876a36d64ef 100644 --- a/Lib9c/TableCSV/CostumeStatSheet.csv +++ b/Lib9c/TableCSV/CostumeStatSheet.csv @@ -230,4 +230,8 @@ id,costume_id,stat_type,stat 237,49900061,SPD,11039685 238,49900061,ATK,6019016 239,40100067,ATK,9008830 -240,40100067,SPD,123435726 \ No newline at end of file +240,40100067,SPD,123435726 +241,40100068,ATK,116889116 +242,40100068,SPD,102277976 +243,40100069,SPD,409111906 +244,40100069,ATK,29222279 \ No newline at end of file From cc1646cdcda212a00b99530f1161cba20f1264d8 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:53 +0900 Subject: [PATCH 18/41] Update ItemRequirementSheet.csv from Google Sheets --- Lib9c/TableCSV/ItemRequirementSheet.csv | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Lib9c/TableCSV/ItemRequirementSheet.csv b/Lib9c/TableCSV/ItemRequirementSheet.csv index 46d00bf6c5e..e7fe6f167c0 100644 --- a/Lib9c/TableCSV/ItemRequirementSheet.csv +++ b/Lib9c/TableCSV/ItemRequirementSheet.csv @@ -577,3 +577,16 @@ item_id,level,mimislevel 40100068,1,1 40100069,1,1 49900061,1,1 +10690000,1,1 +10690001,1,1 +10690002,1,1 +10690003,1,1 +10790001,1,1 +10790002,1,1 +10790003,1,1 +10790004,1,1 +10790005,1,1 +10790006,1,1 +10790007,1,1 +10790008,1,1 +10790009,1,1 From 45ac72f4bc032b95d4c3b6226ad32728250addc1 Mon Sep 17 00:00:00 2001 From: jaeho0103 Date: Tue, 29 Sep 2026 18:50:53 +0900 Subject: [PATCH 19/41] Update CollectionSheet.csv from Google Sheets --- Lib9c/TableCSV/CollectionSheet.csv | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Lib9c/TableCSV/CollectionSheet.csv b/Lib9c/TableCSV/CollectionSheet.csv index 5503735f1c7..6bff3b21a69 100644 --- a/Lib9c/TableCSV/CollectionSheet.csv +++ b/Lib9c/TableCSV/CollectionSheet.csv @@ -920,3 +920,24 @@ id,item_id1,count1,level1,skill1,item_id2,count2,level2,skill2,item_id3,count3,l 1000270,800129,192,0,FALSE,,,,,,,,,,,,,,,,,,,,,SPD,Percentage,36,,,,,, 1000271,800129,1296,0,FALSE,,,,,,,,,,,,,,,,,,,,,SPD,Percentage,90,,,,,, 300076,49900060,1,,FALSE,,,,,,,,,,,,,,,,,,,,,ATK,Percentage,3,SPD,Percentage,3,HP,Percentage,3 +300077,49900061,1,,FALSE,,,,,,,,,,,,,,,,,,,,,ATK,Percentage,3,SPD,Percentage,3,HP,Percentage,3 +1000272,10690000,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,DEF,Add,3972528,HP,Add,16379388,ATK,Add,1792638 +1000273,10690001,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,HP,Add,43111854,ATK,Add,2209824,DEF,Add,1773180 +1000274,10690002,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,HP,Add,72315072,SPD,Add,7126470,,, +1000275,10690003,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,ATK,Add,4128786,ATK,Add,2728350,,, +1000276,10790001,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,ATK,Add,6857136,,,,,, +1000277,10790002,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,ATK,Add,5142852,HIT,Add,7826076,,, +1000278,10790003,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,ATK,Add,5142852,SPD,Add,5999994,,, +1000279,10790004,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,DEF,Add,6857136,,,,,, +1000280,10790005,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,DEF,Add,5142852,HIT,Add,7826076,,, +1000281,10790006,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,DEF,Add,5142852,SPD,Add,5999994,,, +1000282,10790007,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,SPD,Add,17999982,ATK,Add,1714284,,, +1000283,10790008,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,SPD,Add,17999982,DEF,Add,1714284,,, +1000284,10790009,1,0,TRUE,,,,,,,,,,,,,,,,,,,,,SPD,Add,23999976,,,,,, +1000285,10690000,1,0,TRUE,10690001,1,0,TRUE,10690002,1,0,TRUE,10690003,1,0,TRUE,,,,,,,,,ATK,Percentage,48,HP,Percentage,48,DEF,Percentage,48 +1000286,10790001,1,0,TRUE,10790004,1,0,TRUE,10790007,1,0,TRUE,,,,,,,,,,,,,ATK,Percentage,48,DEF,Percentage,48,SPD,Percentage,48 +1000287,10790002,1,0,TRUE,10790005,1,0,TRUE,10790008,1,0,TRUE,,,,,,,,,,,,,ATK,Percentage,48,DEF,Percentage,48,SPD,Percentage,48 +1000288,10790003,1,0,TRUE,10790006,1,0,TRUE,10790009,1,0,TRUE,,,,,,,,,,,,,ATK,Percentage,48,DEF,Percentage,48,SPD,Percentage,48 +300078,40100068,1,,FALSE,,,,,,,,,,,,,,,,,,,,,ATK,Add,87666837,SPD,Add,76708482,,, +300079,40100069,1,,FALSE,,,,,,,,,,,,,,,,,,,,,SPD,Add,306833930,ATK,Add,21916709,,, +300080,40100068,1,,FALSE,40100069,1,,FALSE,,,,,,,,,,,,,,,,,HP,Percentage,42,ATK,Percentage,42,SPD,Percentage,42 From 4699362cffb9a9623bcee00ae3d0b10fadfecf74 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Tue, 29 Sep 2026 20:15:21 +0900 Subject: [PATCH 20/41] =?UTF-8?q?test:=20G9=20=ED=95=A9=EC=84=B1=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v200490 TableCSV 에서 8등급 합성 행이 열리고(8,*,4,10000) Ultimate 아이템이 실제로 들어오면서, 가짜 G9 행을 주입하고 '8,Aura,1,0' 치환을 전제하던 SynthesizingIntoAGradeTheSheetsAddNeedsNoCodeChange 가 깨졌다. 주입 없이 공개 시트로 Aura/Grimoire/FullCostume 8→9 합성을 검증하는 Theory 로 바꾸고, 성공률은 열림(>0)만 단언한 뒤 결정성을 위해 100% 로 고정한다. Co-Authored-By: Claude Opus 5.5 --- .Lib9c.Tests/Action/SynthesizeTest.cs | 81 +++++++++++++-------------- 1 file changed, 38 insertions(+), 43 deletions(-) diff --git a/.Lib9c.Tests/Action/SynthesizeTest.cs b/.Lib9c.Tests/Action/SynthesizeTest.cs index 22463d60f6d..6fc0affceb4 100644 --- a/.Lib9c.Tests/Action/SynthesizeTest.cs +++ b/.Lib9c.Tests/Action/SynthesizeTest.cs @@ -3,6 +3,7 @@ namespace Lib9c.Tests.Action; using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using Bencodex.Types; using Libplanet.Action.State; using Libplanet.Crypto; @@ -635,64 +636,58 @@ public void ExecuteDrawsOnlyListedItems() } /// - /// A grade above the highest one the sheets currently carry arrives by sheet rows alone. Give - /// an Aura to be, a recipe to be built from and a weight to be - /// drawn by, open the grade 8 row for synthesis, and grade 8 material synthesizes into it with - /// no code change. This is what removing the hard-coded cap in + /// arrived by sheet rows alone: the sheets ship its items with a + /// weight to be drawn by and open the grade 8 rows for synthesis, and grade 8 material + /// synthesizes into it with no code change. This is what removing the hard-coded cap in /// buys. /// - [Fact] - public void SynthesizingIntoAGradeTheSheetsAddNeedsNoCodeChange() + /// The item sub type synthesized from grade 8 into grade 9. + [Theory] + [InlineData(ItemSubType.Aura)] + [InlineData(ItemSubType.Grimoire)] + [InlineData(ItemSubType.FullCostume)] + public void SynthesizingIntoAGradeTheSheetsAddNeedsNoCodeChange(ItemSubType itemSubType) { - const int ultimateAuraId = 10690000; - var sheets = new Dictionary(Sheets); - sheets["EquipmentItemSheet"] = AppendRow( - sheets["EquipmentItemSheet"], - $"{ultimateAuraId},Ymir Aura G9,Aura,{(int)Grade.Ultimate},Normal,0,ATK,10,0,10620001,1300000"); - sheets["EquipmentItemRecipeSheet"] = AppendRow( - sheets["EquipmentItemRecipeSheet"], - $"336,{ultimateAuraId},0,0,0,0,10000000,999,106900001,,,0,Aura"); - - // Reuses the Aegis Aura option rows: this test is about the grade, not the stats. - sheets["EquipmentItemSubRecipeSheetV2"] = AppendRow( - sheets["EquipmentItemSubRecipeSheetV2"], - "106900001,0,0,1,,,,,,,1068000211,10000,0,1068000212,10000,0,1068000213,10000,0,,,,FALSE,1"); - sheets["SynthesizeWeightSheet"] = AppendRow( - sheets["SynthesizeWeightSheet"], - $"{ultimateAuraId},10000"); - - // Grade 8 Aura ships closed (`8,Aura,1,0`); open it at the coefficient the plan calls for. - var openedSynthesizeSheet = sheets["SynthesizeSheet"].Replace("8,Aura,1,0", "8,Aura,4,10000"); - Assert.NotEqual(sheets["SynthesizeSheet"], openedSynthesizeSheet); - sheets["SynthesizeSheet"] = openedSynthesizeSheet; - - var tableSheets = new TableSheets(sheets); + // Grade 8 shipped closed (`8,,1,0`) until the Ultimate items did; it must be open now. + var synthesizeData = TableSheets.SynthesizeSheet[(int)Grade.Transcendent] + .RequiredCountDict[itemSubType]; + Assert.True(synthesizeData.SucceedRate > SynthesizeSheet.SucceedRateMin); + + // Only whether the roll succeeds is left to the rate the sheet ships, so pin it to certain: + // this test is about the grade being reachable, not about how often. + var synthesizeSheet = new SynthesizeSheet(); + synthesizeSheet.Set(Regex.Replace( + Sheets[nameof(SynthesizeSheet)], + $@"^{(int)Grade.Transcendent},{itemSubType},(\d+),\d+", + $"{(int)Grade.Transcendent},{itemSubType},$1,{SynthesizeSheet.SucceedRateMax}", + RegexOptions.Multiline)); + Assert.Equal( + SynthesizeSheet.SucceedRateMax, + synthesizeSheet[(int)Grade.Transcendent].RequiredCountDict[itemSubType].SucceedRate); + var inputData = new SynthesizeSimulator.InputData { Grade = Grade.Transcendent, - ItemSubType = ItemSubType.Aura, - MaterialCount = 4, - SynthesizeSheet = tableSheets.SynthesizeSheet, - SynthesizeWeightSheet = tableSheets.SynthesizeWeightSheet, - CostumeItemSheet = tableSheets.CostumeItemSheet, - EquipmentItemSheet = tableSheets.EquipmentItemSheet, - EquipmentItemRecipeSheet = tableSheets.EquipmentItemRecipeSheet, - EquipmentItemSubRecipeSheetV2 = tableSheets.EquipmentItemSubRecipeSheetV2, - EquipmentItemOptionSheet = tableSheets.EquipmentItemOptionSheet, - SkillSheet = tableSheets.SkillSheet, + ItemSubType = itemSubType, + MaterialCount = synthesizeData.RequiredCount, + SynthesizeSheet = synthesizeSheet, + SynthesizeWeightSheet = TableSheets.SynthesizeWeightSheet, + CostumeItemSheet = TableSheets.CostumeItemSheet, + EquipmentItemSheet = TableSheets.EquipmentItemSheet, + EquipmentItemRecipeSheet = TableSheets.EquipmentItemRecipeSheet, + EquipmentItemSubRecipeSheetV2 = TableSheets.EquipmentItemSubRecipeSheetV2, + EquipmentItemOptionSheet = TableSheets.EquipmentItemOptionSheet, + SkillSheet = TableSheets.SkillSheet, RandomObject = new TestRandom(), }; var result = Assert.Single(SynthesizeSimulator.Simulate(inputData)); Assert.True(result.IsSuccess); - Assert.Equal(ultimateAuraId, result.ItemBase.Id); + Assert.Equal(itemSubType, result.ItemBase.ItemSubType); Assert.Equal((int)Grade.Ultimate, result.ItemBase.Grade); } - private static string AppendRow(string csv, string row) => - csv.EndsWith("\n") ? csv + row + "\n" : csv + "\n" + row + "\n"; - private static HashSet GetResultPool(Grade grade, ItemSubType itemSubType) => itemSubType is ItemSubType.FullCostume or ItemSubType.Title ? SynthesizeSimulator.GetSynthesizeResultPool(grade, itemSubType, TableSheets.CostumeItemSheet) From 10f2669dddbe90dc57b63e832455af7df93308f1 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Tue, 6 Oct 2026 14:49:23 +0900 Subject: [PATCH 21/41] =?UTF-8?q?fix:=20=EC=A0=81=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EC=8A=A4=ED=83=AF=20=EB=B3=B4=EC=A0=95=EA=B0=92=EC=9D=84=20lon?= =?UTF-8?q?g=20=EC=9C=BC=EB=A1=9C=20=ED=8C=8C=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StageSheet(EventDungeonStageSheet 상속 포함), AdventureBossFloorSheet, InfiniteTowerFloorSheet 의 hp/atk/def/cri/hit/spd 보정값을 TryParseInt 로 읽고 있어서, int32 를 넘는 값은 에러 없이 continue 로 빠졌다. 몬스터가 의도보다 약해지는 쪽이다. StatModifier.Value 는 이미 long 이라 파서만 TryParseLong 으로 바꾼다. Co-Authored-By: Claude Opus 5.5 --- .../EnemyInitialStatModifierParseTest.cs | 110 ++++++++++++++++++ .../AdventureBoss/AdventureBossFloorSheet.cs | 2 +- Lib9c/TableData/InfiniteTowerFloorSheet.cs | 2 +- Lib9c/TableData/WorldAndStage/StageSheet.cs | 2 +- 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 .Lib9c.Tests/TableData/EnemyInitialStatModifierParseTest.cs diff --git a/.Lib9c.Tests/TableData/EnemyInitialStatModifierParseTest.cs b/.Lib9c.Tests/TableData/EnemyInitialStatModifierParseTest.cs new file mode 100644 index 00000000000..a191879ce2f --- /dev/null +++ b/.Lib9c.Tests/TableData/EnemyInitialStatModifierParseTest.cs @@ -0,0 +1,110 @@ +namespace Lib9c.Tests.TableData +{ + using System.Collections.Generic; + using System.Linq; + using Nekoyume.Model.Stat; + using Nekoyume.TableData; + using Nekoyume.TableData.AdventureBoss; + using Nekoyume.TableData.Event; + using Xunit; + + /// + /// Enemy initial stat modifiers must be parsed as . + /// A value above used to fail int parsing and + /// was silently dropped, making the enemy weaker instead of stronger. + /// + public class EnemyInitialStatModifierParseTest + { + private const long OverInt32 = 3_000_000_000L; + + private static readonly Dictionary Sheets = + TableSheetsImporter.ImportSheets(); + + // Values within int32 must keep parsing exactly as before (history replay); + // values above it must no longer be dropped. + public static IEnumerable Values => new[] + { + new object[] { 1_610_612_710L }, + new object[] { (long)int.MaxValue }, + new object[] { int.MaxValue + 1L }, + new object[] { OverInt32 }, + }; + + [Theory] + [MemberData(nameof(Values))] + public void StageSheet(long value) + { + var sheet = new StageSheet(); + sheet.Set(WithFirstRowField(nameof(StageSheet), 3, value)); + + AssertHpModifier(sheet.First!.EnemyInitialStatModifiers, value); + } + + [Theory] + [MemberData(nameof(Values))] + public void EventDungeonStageSheet(long value) + { + var sheet = new EventDungeonStageSheet(); + sheet.Set(WithFirstRowField(nameof(EventDungeonStageSheet), 3, value)); + + AssertHpModifier(sheet.First!.EnemyInitialStatModifiers, value); + } + + [Theory] + [MemberData(nameof(Values))] + public void AdventureBossFloorSheet(long value) + { + var sheet = new AdventureBossFloorSheet(); + sheet.Set(WithFirstRowField(nameof(AdventureBossFloorSheet), 4, value)); + + AssertHpModifier(sheet.First!.EnemyInitialStatModifiers, value); + } + + [Theory] + [MemberData(nameof(Values))] + public void InfiniteTowerFloorSheet(long value) + { + var sheet = new InfiniteTowerFloorSheet(); + sheet.Set(WithFirstRowField(nameof(InfiniteTowerFloorSheet), 47, value)); + + AssertHpModifier(sheet.First!.EnemyInitialStatModifiers, value); + } + + [Fact] + public void ApplyOverInt32ModifierToEnemyStats() + { + // Enemies get the modifiers through CharacterStats -> Stats.Modify. + var characterRow = new TableSheets(Sheets).CharacterSheet.First!; + var modifiers = new[] + { + new StatModifier(StatType.HP, StatModifier.OperationType.Percentage, OverInt32), + }; + var plain = new CharacterStats(characterRow, 1); + var modified = new CharacterStats(characterRow, 1, modifiers); + + Assert.True(modified.HP > int.MaxValue); + Assert.Equal(plain.HP + plain.HP * OverInt32 / 100, modified.HP); + } + + /// + /// Returns the header and the first data row of the sheet, with the + /// field at replaced by . + /// + private static string WithFirstRowField(string sheetName, int index, long value) + { + var lines = Sheets[sheetName].Split('\n') + .Select(line => line.TrimEnd('\r')) + .ToArray(); + var fields = lines[1].Split(','); + fields[index] = value.ToString(); + return $"{lines[0]}\n{string.Join(",", fields)}"; + } + + private static void AssertHpModifier(IEnumerable modifiers, long expected) + { + var hp = Assert.Single(modifiers, m => m.StatType == StatType.HP); + Assert.Equal(StatModifier.OperationType.Percentage, hp.Operation); + Assert.Equal(expected, hp.Value); + } + } +} diff --git a/Lib9c/TableData/AdventureBoss/AdventureBossFloorSheet.cs b/Lib9c/TableData/AdventureBoss/AdventureBossFloorSheet.cs index 987aa4741b7..21268957277 100644 --- a/Lib9c/TableData/AdventureBoss/AdventureBossFloorSheet.cs +++ b/Lib9c/TableData/AdventureBoss/AdventureBossFloorSheet.cs @@ -55,7 +55,7 @@ public override void Set(IReadOnlyList fields) EnemyInitialStatModifiers = new List(); for (var i = 0; i < 6; i++) { - if (!TryParseInt(fields[4 + i], out var option) || option == 0) + if (!TryParseLong(fields[4 + i], out var option) || option == 0) continue; switch (i) diff --git a/Lib9c/TableData/InfiniteTowerFloorSheet.cs b/Lib9c/TableData/InfiniteTowerFloorSheet.cs index f590600ed41..ea161e94952 100644 --- a/Lib9c/TableData/InfiniteTowerFloorSheet.cs +++ b/Lib9c/TableData/InfiniteTowerFloorSheet.cs @@ -366,7 +366,7 @@ public override void Set(IReadOnlyList fields) for (var i = 0; i < 6; i++) { var fieldIndex = 47 + i; - if (fieldIndex < fields.Count && TryParseInt(fields[fieldIndex], out var option) && option != 0) + if (fieldIndex < fields.Count && TryParseLong(fields[fieldIndex], out var option) && option != 0) { switch (i) { diff --git a/Lib9c/TableData/WorldAndStage/StageSheet.cs b/Lib9c/TableData/WorldAndStage/StageSheet.cs index 3f5ec3d430b..4094c4daf73 100644 --- a/Lib9c/TableData/WorldAndStage/StageSheet.cs +++ b/Lib9c/TableData/WorldAndStage/StageSheet.cs @@ -118,7 +118,7 @@ public override void Set(IReadOnlyList fields) EnemyInitialStatModifiers = new List(); for (var i = 0; i < 6; i++) { - if (!TryParseInt(fields[3 + i], out var option) || + if (!TryParseLong(fields[3 + i], out var option) || option == 0) continue; From e70e1c6515874914c13344f08aaad0dc39663a76 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Thu, 8 Oct 2026 13:55:31 +0900 Subject: [PATCH 22/41] =?UTF-8?q?feat:=20BoostScheduleSheet=20=EB=A1=9C=20?= =?UTF-8?q?=EC=86=8C=ED=99=98=20=EB=B3=B4=EC=9E=A5=20=EA=B0=9C=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=B0=EA=B0=84=20=ED=95=9C=EC=A0=95=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이벤트성 수치 조정(보상 증가·비용 할인)을 원본 시트를 덮어쓰고 되돌리는 대신 블록 구간 [start_block, end_block) 동안만 적용되는 행으로 표현한다. 첫 적용 대상은 Aura/Rune/Costume 소환의 등급 보장 개수. - 행 파싱은 절대 throw 하지 않고, 깨진 행은 invalid 로 남겨 매칭에서 제외한다 - target 은 문자열로만 매칭해서 구버전 노드가 모르는 target 도 읽을 수 있다 - 시트가 패치된 적 없으면 TryGetPatchedSheet 로 기존 동작 그대로 (재실행 동일) - 부스트는 이미 있는 보장만 조정하고 새로 만들지 않으며, 최소 1회 일반 뽑기를 남긴다 - PatchTableSheet(Compressed) 에서 ValidateCsv 로 깨진 스케줄을 패치 시점에 거부 Co-Authored-By: Claude Opus 5.5 --- .../Action/PatchTableSheetCompressedTest.cs | 32 ++ .Lib9c.Tests/Action/PatchTableSheetTest.cs | 44 ++ .Lib9c.Tests/Action/Summon/AuraSummonTest.cs | 68 +++ .../Action/Summon/CostumeSummonTest.cs | 67 +++ .Lib9c.Tests/Action/Summon/RuneSummonTest.cs | 118 ++++ .Lib9c.Tests/Helper/SummonHelperTest.cs | 114 ++++ .../TableData/BoostScheduleSheetTest.cs | 266 +++++++++ .Lib9c.Tests/TableSheets.cs | 2 + Lib9c/Action/AuraSummon.cs | 18 +- Lib9c/Action/CostumeSummon.cs | 18 +- Lib9c/Action/PatchTableSheet.cs | 7 + Lib9c/Action/PatchTableSheetCompressed.cs | 7 + Lib9c/Action/RuneSummon.cs | 31 +- Lib9c/Helper/SummonHelper.cs | 63 ++- Lib9c/TableCSV/BoostScheduleSheet.csv | 1 + Lib9c/TableCSV/BoostScheduleSheet.csv.meta | 7 + Lib9c/TableData/BoostScheduleSheet.cs | 511 ++++++++++++++++++ 17 files changed, 1357 insertions(+), 17 deletions(-) create mode 100644 .Lib9c.Tests/TableData/BoostScheduleSheetTest.cs create mode 100644 Lib9c/TableCSV/BoostScheduleSheet.csv create mode 100644 Lib9c/TableCSV/BoostScheduleSheet.csv.meta create mode 100644 Lib9c/TableData/BoostScheduleSheet.cs diff --git a/.Lib9c.Tests/Action/PatchTableSheetCompressedTest.cs b/.Lib9c.Tests/Action/PatchTableSheetCompressedTest.cs index be427a5f966..0cefbbd7685 100644 --- a/.Lib9c.Tests/Action/PatchTableSheetCompressedTest.cs +++ b/.Lib9c.Tests/Action/PatchTableSheetCompressedTest.cs @@ -155,6 +155,38 @@ public void Execute_Throw_SheetRowValidateException_WhenRestrictionSheetIsMalfor Assert.Throws(() => action.Execute(_context)); } + [Fact] + public void Execute_BoostScheduleSheet() + { + const string csvData = + "id,target,target_id,op,value,start_block,end_block\n" + + "1,EQUIPMENT_SUMMON_GUARANTEE,10001,ADD,1,100,200\n"; + var action = new PatchTableSheetCompressed + { + TableName = nameof(BoostScheduleSheet), + CompressedTableCsv = PatchTableSheetCompressed.CompressCsv(csvData), + }; + + var nextState = action.Execute(_context); + + Assert.Equal(csvData, nextState.GetSheetCsv()); + } + + [Fact] + public void Execute_Throw_SheetRowValidateException_WhenBoostScheduleSheetIsMalformed() + { + const string csvData = + "id,target,target_id,op,value,start_block,end_block\n" + + "1,EQUIPMENT_SUMMON_GUARANTEE,10001,MUL,0,100,200\n"; + var action = new PatchTableSheetCompressed + { + TableName = nameof(BoostScheduleSheet), + CompressedTableCsv = PatchTableSheetCompressed.CompressCsv(csvData), + }; + + Assert.Throws(() => action.Execute(_context)); + } + /// /// Tests and analyzes the compression benefits using RuneOptionSheet.csv data. /// diff --git a/.Lib9c.Tests/Action/PatchTableSheetTest.cs b/.Lib9c.Tests/Action/PatchTableSheetTest.cs index 00c8122d040..bc77a786604 100644 --- a/.Lib9c.Tests/Action/PatchTableSheetTest.cs +++ b/.Lib9c.Tests/Action/PatchTableSheetTest.cs @@ -153,6 +153,50 @@ public void Execute_Throw_SheetRowValidateException_WhenRestrictionSheetIsMalfor })); } + [Fact] + public void Execute_BoostScheduleSheet() + { + const string csv = + "id,target,target_id,op,value,start_block,end_block\n" + + "1,EQUIPMENT_SUMMON_GUARANTEE,10001,ADD,1,100,200\n"; + var action = new PatchTableSheet + { + TableName = nameof(BoostScheduleSheet), + TableCsv = csv, + }; + + var nextState = action.Execute( + new ActionContext + { + BlockIndex = 0, + PreviousState = _initialState, + }); + + Assert.Equal(csv, nextState.GetSheetCsv()); + } + + [Theory] + [InlineData("id,target,target_id,op,value,start_block,end_block\n1,EQUIPMENT_SUMMON_GUARANTEE,10001,ADD,1.5,100,200\n")] + [InlineData("id,target,target_id,op,value,start_block,end_block\n1,EQUIPMENT_SUMMON_GUARANTEE,10001,MUL,1.5,200,100\n")] + [InlineData("id,target,target_id,op,value,end_block,start_block\n1,EQUIPMENT_SUMMON_GUARANTEE,10001,ADD,1,100,200\n")] + public void Execute_Throw_SheetRowValidateException_WhenBoostScheduleSheetIsMalformed( + string csv) + { + var action = new PatchTableSheet + { + TableName = nameof(BoostScheduleSheet), + TableCsv = csv, + }; + + Assert.Throws( + () => action.Execute( + new ActionContext + { + BlockIndex = 0, + PreviousState = _initialState, + })); + } + [Fact] public void CheckPermission() { diff --git a/.Lib9c.Tests/Action/Summon/AuraSummonTest.cs b/.Lib9c.Tests/Action/Summon/AuraSummonTest.cs index 92c0d70e6bb..4ec289e2554 100644 --- a/.Lib9c.Tests/Action/Summon/AuraSummonTest.cs +++ b/.Lib9c.Tests/Action/Summon/AuraSummonTest.cs @@ -16,6 +16,7 @@ namespace Lib9c.Tests.Action.Summon using Nekoyume.Model.Item; using Nekoyume.Model.State; using Nekoyume.Module; + using Nekoyume.TableData; using Nekoyume.TableData.Summon; using Xunit; @@ -332,6 +333,73 @@ public void SimulateSummon_WithoutGradeGuarantee_ShouldUseOriginalLogic(int summ Assert.DoesNotContain(result, i => i.Item2.Grade >= minGrade); } + [Theory] + [InlineData(150, true)] // inside [100, 200) + [InlineData(250, false)] // after the schedule ends + public void Execute_AppliesScheduledGuaranteeBoost(long blockIndex, bool scheduled) + { + // Group 10001 guarantees 2 auras of grade >= 4 per 110 summons; the schedule adds 20. + const int groupId = 10001; + const int seed = 1; + var random = new TestRandom(seed); + var summonRow = _tableSheets.EquipmentSummonSheet[groupId]; + var state = _initialState.SetLegacyState( + Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)), + BoostCsv(groupId).Serialize()); + var material = _tableSheets.MaterialItemSheet[summonRow.CostMaterial]; + _avatarState.inventory.AddItem( + ItemFactory.CreateItem(material, random), + 100 * summonRow.CostMaterialCount); + state = state.SetAvatarState(_avatarAddress, _avatarState); + + var ctx = new ActionContext + { + PreviousState = state, + Signer = _agentAddress, + BlockIndex = blockIndex, + }; + ctx.SetRandom(random); + var nextState = new AuraSummon(_avatarAddress, groupId, 100).Execute(ctx); + + var boost = ExpectedBoost(groupId, blockIndex); + Assert.Equal(scheduled, boost is not null); + var expected = AuraSummon.SimulateSummon( + string.Empty, + _tableSheets.EquipmentItemRecipeSheet, + _tableSheets.EquipmentItemSheet, + _tableSheets.EquipmentItemSubRecipeSheetV2, + _tableSheets.EquipmentItemOptionSheet, + _tableSheets.SkillSheet, + summonRow, + 100, + new TestRandom(seed), + blockIndex, + boost) + .Select(pair => pair.Item2) + .ToList(); + var actual = nextState.GetAvatarState(_avatarAddress).inventory.Equipments.ToList(); + Assert.Equal( + expected.Select(e => e.Id).OrderBy(id => id), + actual.Select(e => e.Id).OrderBy(id => id)); + + if (scheduled) + { + var highGrade = expected.Count(e => e.Grade >= summonRow.MinimumGrade110); + Assert.True(highGrade >= 22, $"expected at least 22 but got {highGrade}"); + } + } + + private static string BoostCsv(int groupId) => + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.EquipmentSummonGuarantee},{groupId},ADD,20,100,200\n"; + + private static BoostScheduleSheet.Row ExpectedBoost(int groupId, long blockIndex) + { + var sheet = new BoostScheduleSheet(); + sheet.Set(BoostCsv(groupId)); + return sheet.FindActive(BoostScheduleSheet.Targets.EquipmentSummonGuarantee, groupId, blockIndex); + } + /// /// Creates a test SummonSheet.Row with grade guarantee settings enabled. /// Uses actual recipe IDs from the test fixtures. diff --git a/.Lib9c.Tests/Action/Summon/CostumeSummonTest.cs b/.Lib9c.Tests/Action/Summon/CostumeSummonTest.cs index 77c77e4d378..97d1799fc5b 100644 --- a/.Lib9c.Tests/Action/Summon/CostumeSummonTest.cs +++ b/.Lib9c.Tests/Action/Summon/CostumeSummonTest.cs @@ -216,6 +216,73 @@ public void SimulateSummon_NoEligibleCostumeRecipes_ShouldThrowException() Assert.Contains("summon group 77777", exception.Message); } + [Theory] + [InlineData(150, true)] // inside [100, 200) + [InlineData(250, false)] // after the schedule ends + public void Execute_AppliesScheduledGuaranteeBoost(long blockIndex, bool scheduled) + { + // Group 50001 guarantees 3 costumes of grade >= 6 per 110 summons; the schedule adds 20. + const int groupId = 50001; + const int seed = 1; + var random = new TestRandom(seed); + var summonRow = _tableSheets.CostumeSummonSheet[groupId]; + var state = _initialState.SetLegacyState( + Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)), + BoostCsv(groupId).Serialize()); + var material = _tableSheets.MaterialItemSheet[summonRow.CostMaterial]; + _avatarState.inventory.AddItem( + ItemFactory.CreateItem(material, random), + 100 * summonRow.CostMaterialCount); + state = state.SetAvatarState(_avatarAddress, _avatarState); + + var ctx = new ActionContext + { + PreviousState = state, + Signer = _agentAddress, + BlockIndex = blockIndex, + }; + ctx.SetRandom(random); + var nextState = new CostumeSummon + { + AvatarAddress = _avatarAddress, + GroupId = groupId, + SummonCount = 100, + }.Execute(ctx); + + var boost = ExpectedBoost(groupId, blockIndex); + Assert.Equal(scheduled, boost is not null); + var expected = CostumeSummon.SimulateSummon( + string.Empty, + _tableSheets.CostumeItemSheet, + summonRow, + 100, + new TestRandom(seed), + boost).ToList(); + var inventory = nextState.GetAvatarState(_avatarAddress).inventory; + foreach (var costume in expected) + { + inventory.TryGetNonFungibleItem(costume.ItemId, out Costume outItem); + Assert.Equal(costume, outItem); + } + + if (scheduled) + { + var highGrade = expected.Count(c => c.Grade >= summonRow.MinimumGrade110); + Assert.True(highGrade >= 23, $"expected at least 23 but got {highGrade}"); + } + } + + private static string BoostCsv(int groupId) => + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.CostumeSummonGuarantee},{groupId},ADD,20,100,200\n"; + + private static BoostScheduleSheet.Row ExpectedBoost(int groupId, long blockIndex) + { + var sheet = new BoostScheduleSheet(); + sheet.Set(BoostCsv(groupId)); + return sheet.FindActive(BoostScheduleSheet.Targets.CostumeSummonGuarantee, groupId, blockIndex); + } + /// /// Creates a test CostumeSummonSheet.Row with grade guarantee settings for testing. /// diff --git a/.Lib9c.Tests/Action/Summon/RuneSummonTest.cs b/.Lib9c.Tests/Action/Summon/RuneSummonTest.cs index 6c31353c469..078f09c331a 100644 --- a/.Lib9c.Tests/Action/Summon/RuneSummonTest.cs +++ b/.Lib9c.Tests/Action/Summon/RuneSummonTest.cs @@ -225,6 +225,124 @@ public void SimulateSummon_NoEligibleRuneRecipes_ShouldThrowException() Assert.Contains("summon group 88888", exception.Message); } + [Theory] + [InlineData(150, true)] // inside [100, 200) + [InlineData(250, false)] // after the schedule ends + public void Execute_AppliesScheduledGuaranteeBoost(long blockIndex, bool scheduled) + { + // Group 20001 guarantees 1 rune of grade >= 6 per 110 summons; the schedule adds 20. + const int groupId = 20001; + const int seed = 1; + var random = new TestRandom(seed); + var summonRow = _tableSheets.RuneSummonSheet[groupId]; + var state = _initialState.SetLegacyState( + Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)), + BoostCsv(groupId).Serialize()); + var material = _tableSheets.MaterialItemSheet[summonRow.CostMaterial]; + _avatarState.inventory.AddItem( + ItemFactory.CreateItem(material, random), + 100 * summonRow.CostMaterialCount); + state = state.SetAvatarState(_avatarAddress, _avatarState); + + var ctx = new ActionContext + { + PreviousState = state, + Signer = _agentAddress, + BlockIndex = blockIndex, + }; + ctx.SetRandom(random); + var nextState = new RuneSummon + { + AvatarAddress = _avatarAddress, + GroupId = groupId, + SummonCount = 100, + }.Execute(ctx); + + var boost = ExpectedBoost(groupId, blockIndex); + Assert.Equal(scheduled, boost is not null); + var expected = RuneSummon.SimulateSummon( + _tableSheets.RuneSheet, + summonRow, + 100, + new TestRandom(seed), + _tableSheets.RuneListSheet, + boost); + var highGrade = 0; + foreach (var (currency, quantity) in expected) + { + var minted = nextState.GetBalance(_avatarAddress, currency) - + state.GetBalance(_avatarAddress, currency); + Assert.Equal(currency * quantity, minted); + if (ExtractRuneIdFromCurrency(currency) is { } runeId && + _tableSheets.RuneListSheet[runeId].Grade >= summonRow.MinimumGrade110) + { + highGrade += quantity / RuneSummon.RuneQuantity; + } + } + + if (scheduled) + { + Assert.True(highGrade >= 21, $"expected at least 21 but got {highGrade}"); + } + } + + [Fact] + public void Execute_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet() + { + // Re-evaluating a block from before the sheet was patched must give the same result. + const int groupId = 20001; + const int seed = 1; + var random = new TestRandom(seed); + var summonRow = _tableSheets.RuneSummonSheet[groupId]; + var state = _initialState.SetLegacyState( + Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)), + Bencodex.Types.Null.Value); + Assert.False(state.TryGetPatchedSheet(out _)); + var material = _tableSheets.MaterialItemSheet[summonRow.CostMaterial]; + _avatarState.inventory.AddItem( + ItemFactory.CreateItem(material, random), + 100 * summonRow.CostMaterialCount); + state = state.SetAvatarState(_avatarAddress, _avatarState); + + var ctx = new ActionContext + { + PreviousState = state, + Signer = _agentAddress, + BlockIndex = 150, + }; + ctx.SetRandom(random); + var nextState = new RuneSummon + { + AvatarAddress = _avatarAddress, + GroupId = groupId, + SummonCount = 100, + }.Execute(ctx); + + var expected = RuneSummon.SimulateSummon( + _tableSheets.RuneSheet, + summonRow, + 100, + new TestRandom(seed), + _tableSheets.RuneListSheet); + foreach (var (currency, quantity) in expected) + { + var minted = nextState.GetBalance(_avatarAddress, currency) - + state.GetBalance(_avatarAddress, currency); + Assert.Equal(currency * quantity, minted); + } + } + + private static string BoostCsv(int groupId) => + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.RuneSummonGuarantee},{groupId},ADD,20,100,200\n"; + + private static BoostScheduleSheet.Row ExpectedBoost(int groupId, long blockIndex) + { + var sheet = new BoostScheduleSheet(); + sheet.Set(BoostCsv(groupId)); + return sheet.FindActive(BoostScheduleSheet.Targets.RuneSummonGuarantee, groupId, blockIndex); + } + /// /// Creates a test RuneSummonSheet.Row with grade guarantee settings enabled. /// diff --git a/.Lib9c.Tests/Helper/SummonHelperTest.cs b/.Lib9c.Tests/Helper/SummonHelperTest.cs index 28147ef1799..38a94ec9459 100644 --- a/.Lib9c.Tests/Helper/SummonHelperTest.cs +++ b/.Lib9c.Tests/Helper/SummonHelperTest.cs @@ -213,6 +213,120 @@ public void GetSummonRecipeIdByRandom_DataConsistencyIssue_ShouldThrowException( Assert.Contains("data consistency issue with recipe ratios", exception.Message); } + [Fact] + public void GetGuaranteeSettings_WithoutBoost_IsUnchanged() + { + var summonRow = CreateTestSummonRow(); + + Assert.Equal((true, 3, 1), SummonHelper.GetGuaranteeSettings(summonRow, 11)); + Assert.Equal((true, 4, 2), SummonHelper.GetGuaranteeSettings(summonRow, 110)); + Assert.Equal((false, 0, 0), SummonHelper.GetGuaranteeSettings(summonRow, 1)); + } + + [Theory] + [InlineData("MUL", "1.5", 11, 1)] // 1 x 1.5 rounds down to 1: a multiplier can be a no-op + [InlineData("MUL", "1.5", 110, 3)] // 2 x 1.5 + [InlineData("ADD", "1", 11, 2)] + [InlineData("ADD", "3", 110, 5)] + [InlineData("MUL", "0.5", 110, 1)] // a boost may lower the count too + public void GetGuaranteeSettings_AppliesBoostToGuaranteeCountOnly( + string op, + string value, + int summonCount, + int expectedCount) + { + var summonRow = CreateTestSummonRow(); + var boost = CreateBoostRow(op, value); + + var (useGuarantee, minimumGrade, guaranteeCount) = + SummonHelper.GetGuaranteeSettings(summonRow, summonCount, boost); + + Assert.True(useGuarantee); + Assert.Equal(summonCount >= 110 ? 4 : 3, minimumGrade); + Assert.Equal(expectedCount, guaranteeCount); + } + + [Theory] + [InlineData(11, 10)] + [InlineData(110, 109)] + public void GetGuaranteeSettings_BoostLeavesAtLeastOneNormalDraw( + int summonCount, + int expectedCount) + { + var summonRow = CreateTestSummonRow(); + var boost = CreateBoostRow("ADD", "1000"); + + var (_, _, guaranteeCount) = + SummonHelper.GetGuaranteeSettings(summonRow, summonCount, boost); + + Assert.Equal(expectedCount, guaranteeCount); + } + + [Fact] + public void GetGuaranteeSettings_BoostNeverCreatesAGuarantee() + { + var boost = CreateBoostRow("ADD", "5"); + + // Below 11 summons there is no guarantee to adjust. + Assert.Equal( + (false, 0, 0), + SummonHelper.GetGuaranteeSettings(CreateTestSummonRow(), 1, boost)); + + // A group without guarantee settings stays without one. + var withoutGuarantee = new SummonSheet.Row(); + withoutGuarantee.Set(new List { "10002", "800201", "10", "0", "101", "1000", "102", "500" }); + Assert.Equal( + (false, 0, 0), + SummonHelper.GetGuaranteeSettings(withoutGuarantee, 11, boost)); + + // Nor does a group whose guarantee count is configured as 0: its guaranteed-grade + // path has never run and could fail for want of an eligible recipe. + var zeroCount = new SummonSheet.Row(); + zeroCount.Set(new List + { + "10003", "800201", "10", "0", "GUARANTEE", "3", "0", "4", "0", "101", "1000", "102", "500", + }); + Assert.Equal( + (true, 3, 0), + SummonHelper.GetGuaranteeSettings(zeroCount, 11, boost)); + } + + [Fact] + public void GetSummonRecipeIdsWithGradeGuarantee_AppliesBoost() + { + var summonRow = CreateTestSummonRow(); + var equipmentItemSheet = CreateTestEquipmentItemSheet(); + var equipmentItemRecipeSheet = CreateTestEquipmentItemRecipeSheet(); + var boost = CreateBoostRow("ADD", "5"); + + // 110 summons guarantee 2 at grade 4; the boost raises that to 7. + var result = SummonHelper.GetSummonRecipeIdsWithGradeGuarantee( + summonRow, 110, new TestRandom(), equipmentItemSheet, equipmentItemRecipeSheet, boost); + + var atLeastMinimumGrade = result.Count(recipeId => + TryGetItemGradeFromRecipeId(recipeId, equipmentItemSheet, equipmentItemRecipeSheet, out var grade) && + grade >= 4); + Assert.Equal(110, result.Count); + Assert.True(atLeastMinimumGrade >= 7, $"expected at least 7 but got {atLeastMinimumGrade}"); + } + + private static BoostScheduleSheet.Row CreateBoostRow(string op, string value) + { + var row = new BoostScheduleSheet.Row(); + row.Set(new List + { + "1", + BoostScheduleSheet.Targets.EquipmentSummonGuarantee, + "10001", + op, + value, + "0", + "100", + }); + Assert.True(row.IsValid); + return row; + } + private static SummonSheet.Row CreateTestSummonRow() { var row = new SummonSheet.Row(); diff --git a/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs b/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs new file mode 100644 index 00000000000..15b7f1332b1 --- /dev/null +++ b/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs @@ -0,0 +1,266 @@ +namespace Lib9c.Tests.TableData +{ + using Nekoyume.TableData; + using Xunit; + + public class BoostScheduleSheetTest + { + private const string Header = "id,target,target_id,op,value,start_block,end_block\n"; + private const string Target = BoostScheduleSheet.Targets.EquipmentSummonGuarantee; + + [Fact] + public void ShippedCsvPassesItsOwnValidator() + { + Assert.True(TableSheetsImporter.TryGetCsv(nameof(BoostScheduleSheet), out var csv)); + BoostScheduleSheet.ValidateCsv(csv); + } + + [Fact] + public void ShippedCsvSchedulesNothing() + { + // The sheet ships empty: an adjustment is an operational decision made by patching a + // chain, never something a release carries in. + Assert.True(TableSheetsImporter.TryGetCsv(nameof(BoostScheduleSheet), out var csv)); + var sheet = new BoostScheduleSheet(); + sheet.Set(csv); + Assert.Empty(sheet); + } + + [Fact] + public void ParsesRow() + { + var sheet = Parse($"1,{Target},10001,MUL,1.5,100,200\n"); + + var row = sheet[1]; + Assert.True(row.IsValid); + Assert.Equal(Target, row.Target); + Assert.Equal(10001, row.TargetId); + Assert.Equal(BoostScheduleSheet.Operation.Mul, row.Op); + Assert.Equal(1.5m, row.Value); + Assert.Equal(100, row.StartBlockIndex); + Assert.Equal(200, row.EndBlockIndex); + } + + [Fact] + public void MemoColumnIsDropped() + { + var sheet = new BoostScheduleSheet(); + sheet.Set( + "id,target,target_id,op,value,start_block,end_block,_memo\n" + + $"1,{Target},10001,ADD,1,100,200,anniversary\n"); + + Assert.True(sheet[1].IsValid); + } + + [Fact] + public void AnyTargetIdMatchesEveryId() + { + var sheet = Parse($"1,{Target},*,ADD,1,100,200\n"); + + Assert.Null(sheet[1].TargetId); + Assert.NotNull(sheet.FindActive(Target, 10001, 150)); + Assert.NotNull(sheet.FindActive(Target, 99999, 150)); + } + + [Theory] + [InlineData("1,,10001,MUL,1.5,100,200")] // empty target + [InlineData("1,TARGET,abc,MUL,1.5,100,200")] // target_id not a number + [InlineData("1,TARGET,0,MUL,1.5,100,200")] // target_id not positive + [InlineData("1,TARGET,10001,POW,1.5,100,200")] // unknown op + [InlineData("1,TARGET,10001,mul,1.5,100,200")] // op is case sensitive + [InlineData("1,TARGET,10001,MUL,1.5x,100,200")] // value not a number + [InlineData("1,TARGET,10001,MUL,0,100,200")] // MUL must be positive + [InlineData("1,TARGET,10001,MUL,-1,100,200")] + [InlineData("1,TARGET,10001,MUL,3000000000,100,200")] // beyond MaxAbsoluteValue + [InlineData("1,TARGET,10001,ADD,0.5,100,200")] // ADD must be integral + [InlineData("1,TARGET,10001,MUL,1.5,-1,200")] // negative start + [InlineData("1,TARGET,10001,MUL,1.5,200,200")] // empty range + [InlineData("1,TARGET,10001,MUL,1.5,300,200")] // reversed range + [InlineData("1,TARGET,10001,MUL,1.5,100")] // missing cell + public void MalformedRowIsKeptInvalidInsteadOfThrowing(string line) + { + // Reading must never throw: every action that reads the sheet would fail with it. + var sheet = Parse(line.Replace("TARGET", Target) + "\n"); + + Assert.False(sheet[1].IsValid); + Assert.Null(sheet.FindActive(Target, 10001, 150)); + } + + [Fact] + public void RowWithoutUsableIdIsDropped() + { + var sheet = Parse( + $"abc,{Target},10001,ADD,1,100,200\n" + + $"0,{Target},10001,ADD,1,100,200\n" + + $"2,{Target},10001,ADD,2,100,200\n"); + + Assert.Single(sheet); + Assert.True(sheet.ContainsKey(2)); + } + + [Fact] + public void DuplicatedIdKeepsTheFirstRowInsteadOfThrowing() + { + var sheet = Parse( + $"1,{Target},10001,ADD,1,100,200\n" + + $"1,{Target},10001,ADD,5,100,200\n"); + + Assert.Single(sheet); + Assert.Equal(1m, sheet[1].Value); + } + + [Theory] + [InlineData(99, false)] + [InlineData(100, true)] // start is inclusive + [InlineData(199, true)] + [InlineData(200, false)] // end is exclusive + public void RangeIsHalfOpen(long blockIndex, bool expected) + { + var sheet = Parse($"1,{Target},10001,ADD,1,100,200\n"); + + Assert.Equal(expected, sheet.FindActive(Target, 10001, blockIndex) is not null); + } + + [Fact] + public void MatchesTargetAndTargetIdExactly() + { + var sheet = Parse($"1,{Target},10001,ADD,1,100,200\n"); + + Assert.Null(sheet.FindActive(BoostScheduleSheet.Targets.RuneSummonGuarantee, 10001, 150)); + Assert.Null(sheet.FindActive(Target.ToLowerInvariant(), 10001, 150)); + Assert.Null(sheet.FindActive(Target, 10002, 150)); + } + + [Fact] + public void TargetNoVersionAppliesIsStillReadable() + { + // A node that predates a target must read the sheet as usual and just find no match. + var sheet = Parse( + "1,SOME_FUTURE_TARGET,1,MUL,2,100,200\n" + + $"2,{Target},10001,ADD,1,100,200\n"); + + Assert.True(sheet[1].IsValid); + Assert.Equal(2, sheet.FindActive(Target, 10001, 150).Id); + } + + [Fact] + public void LowestIdWinsAndRowsNeverStack() + { + var sheet = Parse( + $"7,{Target},*,ADD,5,100,200\n" + + $"3,{Target},10001,ADD,1,150,250\n"); + + Assert.Equal(7, sheet.FindActive(Target, 10001, 120).Id); + Assert.Equal(3, sheet.FindActive(Target, 10001, 160).Id); + Assert.Equal(3, sheet.FindActive(Target, 10001, 220).Id); + Assert.Equal(7, sheet.FindActive(Target, 10002, 160).Id); + } + + [Fact] + public void InvalidRowDoesNotShadowAValidOne() + { + var sheet = Parse( + $"1,{Target},10001,MUL,0,100,200\n" + + $"2,{Target},10001,ADD,1,100,200\n"); + + Assert.Equal(2, sheet.FindActive(Target, 10001, 150).Id); + } + + [Fact] + public void EmptySheetFindsNothing() + { + Assert.Null(new BoostScheduleSheet().FindActive(Target, 10001, 150)); + Assert.Null(Parse(string.Empty).FindActive(Target, 10001, 150)); + } + + [Theory] + [InlineData("MUL", "1.5", 1, 1)] // rounds down: a small base can stay put + [InlineData("MUL", "1.5", 2, 3)] + [InlineData("MUL", "1.5", 3, 4)] + [InlineData("MUL", "2", 3, 6)] + [InlineData("MUL", "0.5", 3, 1)] + [InlineData("ADD", "1", 1, 2)] + [InlineData("ADD", "-5", 3, 0)] // clamped at zero + [InlineData("MUL", "2147483647", 2, int.MaxValue)] // clamped at int.MaxValue + [InlineData("ADD", "2147483647", int.MaxValue, int.MaxValue)] + public void Apply(string op, string value, int baseValue, int expected) + { + var sheet = Parse($"1,{Target},10001,{op},{value},100,200\n"); + + Assert.Equal(expected, sheet[1].Apply(baseValue)); + } + + [Fact] + public void ValidateCsvAcceptsWellFormedRows() + { + BoostScheduleSheet.ValidateCsv( + "id,target,target_id,op,value,start_block,end_block,_memo\n" + + $"1,{Target},10001,MUL,1.5,100,200,memo\n" + + $"2,{Target},*,ADD,-1,0,1,\n" + + "3,SOME_FUTURE_TARGET,1,MUL,2,100,200,\n"); + } + + [Fact] + public void ValidateCsvAcceptsHeaderOnly() + { + BoostScheduleSheet.ValidateCsv(Header); + } + + [Fact] + public void ValidateCsvSkipsBlankLinesASpreadsheetExportLeaves() + { + BoostScheduleSheet.ValidateCsv( + Header + + ",,,,,,\n" + + $"1,{Target},10001,ADD,1,100,200\n" + + "\n"); + } + + [Theory] + [InlineData(",EQUIPMENT_SUMMON_GUARANTEE,10001,ADD,1,100,200\n")] // id left out + [InlineData("_1,EQUIPMENT_SUMMON_GUARANTEE,10001,ADD,1,100,200\n")] + public void ValidateCsvRejectsRowsTheSheetWouldSkip(string row) + { + // Sheet.Set drops these lines without a word, so accepting them would land an + // adjustment that never applies. + Assert.Throws( + () => BoostScheduleSheet.ValidateCsv(Header + row)); + } + + [Fact] + public void ValidateCsvRejectsADroppedColumnInFront() + { + // With "_memo" first, every row whose memo is empty starts with "," and is skipped. + Assert.Throws( + () => BoostScheduleSheet.ValidateCsv( + "_memo,id,target,target_id,op,value,start_block,end_block\n" + + $",1,{Target},10001,ADD,1,100,200\n")); + } + + [Theory] + [InlineData("id,target,target_id,op,value,end_block,start_block\n")] // column order + [InlineData("id,target,op,value,start_block,end_block\n")] // missing column + [InlineData(Header + "0,T,10001,ADD,1,100,200\n")] // id not positive + [InlineData(Header + "abc,T,10001,ADD,1,100,200\n")] // id not a number + [InlineData(Header + "1,T,10001,ADD,1,100,200\n1,T,10001,ADD,1,100,200\n")] // duplicated id + [InlineData(Header + "1,,10001,ADD,1,100,200\n")] // empty target + [InlineData(Header + "1,T,x,ADD,1,100,200\n")] // target_id + [InlineData(Header + "1,T,10001,SET,1,100,200\n")] // op + [InlineData(Header + "1,T,10001,MUL,0,100,200\n")] // value + [InlineData(Header + "1,T,10001,ADD,1.5,100,200\n")] // value + [InlineData(Header + "1,T,10001,ADD,1,200,100\n")] // range + [InlineData(Header + "1,T,10001,ADD,1,100\n")] // missing cell + [InlineData(Header + "1,T,10001,ADD,1,100,200,extra\n")] // extra cell + public void ValidateCsvRejectsMalformedSchedule(string csv) + { + Assert.Throws(() => BoostScheduleSheet.ValidateCsv(csv)); + } + + private static BoostScheduleSheet Parse(string rows) + { + var sheet = new BoostScheduleSheet(); + sheet.Set(Header + rows); + return sheet; + } + } +} diff --git a/.Lib9c.Tests/TableSheets.cs b/.Lib9c.Tests/TableSheets.cs index 9fc0d2918ec..67859f157d3 100644 --- a/.Lib9c.Tests/TableSheets.cs +++ b/.Lib9c.Tests/TableSheets.cs @@ -303,6 +303,8 @@ public TableSheets(Dictionary sheets, bool ignoreFailedGetProper public RestrictionSheet RestrictionSheet { get; private set; } + public BoostScheduleSheet BoostScheduleSheet { get; private set; } + public void ItemSheetInitialize() { ItemSheet ??= new ItemSheet(); diff --git a/Lib9c/Action/AuraSummon.cs b/Lib9c/Action/AuraSummon.cs index 3a41e829d80..79620f4532c 100644 --- a/Lib9c/Action/AuraSummon.cs +++ b/Lib9c/Action/AuraSummon.cs @@ -82,6 +82,10 @@ protected override void LoadPlainValueInternal( /// Number of items to summon (before 10+1 bonus) /// Random number generator /// Current block index for item creation + /// + /// The row adjusting this group's guarantee count, or + /// null for none. A caller replaying a summon must pass what the action resolved. + /// /// Collection of (recipe ID, equipment) pairs public static IEnumerable<(int, Equipment)> SimulateSummon( string addressesHex, @@ -93,7 +97,8 @@ protected override void LoadPlainValueInternal( SummonSheet.Row summonRow, int summonCount, IRandom random, - long blockIndex + long blockIndex, + BoostScheduleSheet.Row guaranteeBoost = null ) { summonCount = SummonHelper.CalculateSummonCount(summonCount); @@ -105,7 +110,7 @@ long blockIndex { // Use grade guarantee system with settings from SummonSheet.Row based on summon count recipeIds = SummonHelper.GetSummonRecipeIdsWithGradeGuarantee( - summonRow, summonCount, random, equipmentItemSheet, recipeSheet); + summonRow, summonCount, random, equipmentItemSheet, recipeSheet, guaranteeBoost); } else { @@ -313,6 +318,12 @@ public override IWorld Execute(IActionContext context) ); } + states.TryGetPatchedSheet(out var boostScheduleSheet); + var guaranteeBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.EquipmentSummonGuarantee, + GroupId, + context.BlockIndex); + var random = context.GetRandom(); var summonResult = SimulateSummon( addressesHex, @@ -322,7 +333,8 @@ public override IWorld Execute(IActionContext context) sheets.GetSheet(), sheets.GetSheet(), summonRow, SummonCount, - random, context.BlockIndex + random, context.BlockIndex, + guaranteeBoost ); foreach (var (recipeId, equipment) in summonResult) diff --git a/Lib9c/Action/CostumeSummon.cs b/Lib9c/Action/CostumeSummon.cs index 89163027d4b..018b680d022 100644 --- a/Lib9c/Action/CostumeSummon.cs +++ b/Lib9c/Action/CostumeSummon.cs @@ -71,13 +71,18 @@ protected override void LoadPlainValueInternal( /// Summon configuration row with recipes and guarantee settings /// Number of costumes to summon (before 10+1 bonus) /// Random number generator + /// + /// The row adjusting this group's guarantee count, or + /// null for none. A caller replaying a summon must pass what the action resolved. + /// /// Collection of summoned costumes public static IEnumerable SimulateSummon( string addressesHex, CostumeItemSheet costumeItemSheet, SummonSheet.Row summonRow, int summonCount, - IRandom random + IRandom random, + BoostScheduleSheet.Row guaranteeBoost = null ) { summonCount = SummonHelper.CalculateSummonCount(summonCount); @@ -89,7 +94,7 @@ IRandom random { // Use grade guarantee system with settings from SummonSheet.Row based on summon count recipeIds = SummonHelper.GetSummonRecipeIdsWithGradeGuarantee( - summonRow, summonCount, random, costumeItemSheet, null); + summonRow, summonCount, random, costumeItemSheet, null, guaranteeBoost); } else { @@ -221,13 +226,20 @@ public override IWorld Execute(IActionContext context) ); } + states.TryGetPatchedSheet(out var boostScheduleSheet); + var guaranteeBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.CostumeSummonGuarantee, + GroupId, + context.BlockIndex); + var random = context.GetRandom(); var summonResult = SimulateSummon( addressesHex, sheets.GetSheet(), summonRow, SummonCount, - random + random, + guaranteeBoost ); foreach (var costume in summonResult) diff --git a/Lib9c/Action/PatchTableSheet.cs b/Lib9c/Action/PatchTableSheet.cs index 24eae051059..7b59e9a33b4 100644 --- a/Lib9c/Action/PatchTableSheet.cs +++ b/Lib9c/Action/PatchTableSheet.cs @@ -87,6 +87,13 @@ public override IWorld Execute(IActionContext context) RestrictionSheet.ValidateCsv(TableCsv); } + // Reject a malformed schedule here: a row that does not parse is read as no + // adjustment, so it would otherwise land and silently never apply. + if (TableName == nameof(BoostScheduleSheet)) + { + BoostScheduleSheet.ValidateCsv(TableCsv); + } + states = states.SetLegacyState(sheetAddress, TableCsv.Serialize()); if (TableName == nameof(GameConfigSheet)) diff --git a/Lib9c/Action/PatchTableSheetCompressed.cs b/Lib9c/Action/PatchTableSheetCompressed.cs index e975ec4d28f..361253a7922 100644 --- a/Lib9c/Action/PatchTableSheetCompressed.cs +++ b/Lib9c/Action/PatchTableSheetCompressed.cs @@ -140,6 +140,13 @@ public override IWorld Execute(IActionContext context) RestrictionSheet.ValidateCsv(tableCsv); } + // Reject a malformed schedule here: a row that does not parse is read as no + // adjustment, so it would otherwise land and silently never apply. + if (TableName == nameof(BoostScheduleSheet)) + { + BoostScheduleSheet.ValidateCsv(tableCsv); + } + states = states.SetLegacyState(sheetAddress, tableCsv.Serialize()); if (TableName == nameof(GameConfigSheet)) diff --git a/Lib9c/Action/RuneSummon.cs b/Lib9c/Action/RuneSummon.cs index 56ec1a3a662..8138e058961 100644 --- a/Lib9c/Action/RuneSummon.cs +++ b/Lib9c/Action/RuneSummon.cs @@ -107,6 +107,12 @@ public override IWorld Execute(IActionContext context) ); } + states.TryGetPatchedSheet(out var boostScheduleSheet); + var guaranteeBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.RuneSummonGuarantee, + GroupId, + context.BlockIndex); + var random = context.GetRandom(); states = Summon( context, @@ -116,7 +122,8 @@ public override IWorld Execute(IActionContext context) SummonCount, random, states, - runeListSheet + runeListSheet, + guaranteeBoost ); Log.Debug( @@ -157,10 +164,12 @@ public static IWorld Summon( int summonCount, IRandom random, IWorld states, - RuneListSheet runeListSheet + RuneListSheet runeListSheet, + BoostScheduleSheet.Row guaranteeBoost = null ) { - var result = SimulateSummon(runeSheet, summonRow, summonCount, random, runeListSheet); + var result = SimulateSummon( + runeSheet, summonRow, summonCount, random, runeListSheet, guaranteeBoost); #pragma warning disable LAA1002 foreach (var pair in result) #pragma warning restore LAA1002 @@ -182,13 +191,18 @@ RuneListSheet runeListSheet /// Number of summons to perform (before 10+1 bonus) /// Random number generator /// Rune list sheet for grade information + /// + /// The row adjusting this group's guarantee count, or + /// null for none. A caller replaying a summon must pass what the action resolved. + /// /// Dictionary mapping rune currencies to quantities public static Dictionary SimulateSummon( RuneSheet runeSheet, SummonSheet.Row summonRow, int summonCount, IRandom random, - RuneListSheet runeListSheet + RuneListSheet runeListSheet, + BoostScheduleSheet.Row guaranteeBoost = null ) { summonCount = SummonHelper.CalculateSummonCount(summonCount); @@ -202,7 +216,7 @@ RuneListSheet runeListSheet // For runes, we'll use a simplified approach since runes don't have traditional grades // We'll use the same logic but with rune-specific grade checking recipeIds = GetRuneSummonRecipeIdsWithGradeGuarantee( - summonRow, summonCount, random, runeSheet, runeListSheet); + summonRow, summonCount, random, runeSheet, runeListSheet, guaranteeBoost); } else { @@ -270,19 +284,22 @@ RuneListSheet runeListSheet /// Random number generator /// Rune sheet containing rune information /// Rune list sheet for grade information + /// Guarantee count adjustment, or null for none. /// List of recipe IDs with grade guarantee applied private static List GetRuneSummonRecipeIdsWithGradeGuarantee( SummonSheet.Row summonRow, int summonCount, IRandom random, RuneSheet runeSheet, - RuneListSheet runeListSheet) + RuneListSheet runeListSheet, + BoostScheduleSheet.Row guaranteeBoost) { var result = new List(); var guaranteedCount = 0; // Get guarantee settings - var (useGuarantee, minimumGrade, guaranteeCount) = SummonHelper.GetGuaranteeSettings(summonRow, summonCount); + var (useGuarantee, minimumGrade, guaranteeCount) = + SummonHelper.GetGuaranteeSettings(summonRow, summonCount, guaranteeBoost); // Process each item one by one to maintain random call order for (var i = 0; i < summonCount; i++) diff --git a/Lib9c/Helper/SummonHelper.cs b/Lib9c/Helper/SummonHelper.cs index 4c12a77908d..db636ffc768 100644 --- a/Lib9c/Helper/SummonHelper.cs +++ b/Lib9c/Helper/SummonHelper.cs @@ -67,8 +67,57 @@ public static int GetSummonRecipeIdByRandom(SummonSheet.Row summonRow, /// /// The summon row containing guarantee settings /// The number of summons + /// + /// The row adjusting this group's guarantee count at the + /// current block, or null for none. See . + /// /// A tuple containing (useGuarantee, minimumGrade, guaranteeCount) public static (bool useGuarantee, int minimumGrade, int guaranteeCount) GetGuaranteeSettings( + SummonSheet.Row summonRow, + int summonCount, + BoostScheduleSheet.Row guaranteeBoost = null) + { + var (useGuarantee, minimumGrade, guaranteeCount) = + GetBaseGuaranteeSettings(summonRow, summonCount); + if (useGuarantee && guaranteeBoost is not null) + { + guaranteeCount = ApplyGuaranteeBoost(guaranteeBoost, guaranteeCount, summonCount); + } + + return (useGuarantee, minimumGrade, guaranteeCount); + } + + /// + /// Applies a row to a configured guarantee count. + /// + /// The row to apply. + /// The guarantee count the summon sheet configures. + /// The number of summons, bonus included. + /// + /// The adjusted count, kept below so that at least one draw + /// stays a normal draw — unless the sheet itself already configures more. + /// + /// + /// A boost adjusts a guarantee but never creates one, since a guarantee that exists only + /// during an event reads as one taken away when the event ends. That also covers a group + /// configured with a count of 0: its guaranteed-grade path has never run, so starting it + /// mid-event could surface a grade with no eligible recipe and fail every summon. + /// + public static int ApplyGuaranteeBoost( + BoostScheduleSheet.Row guaranteeBoost, + int guaranteeCount, + int summonCount) + { + if (guaranteeCount <= 0) + { + return guaranteeCount; + } + + var cap = Math.Max(guaranteeCount, summonCount - 1); + return Math.Min(guaranteeBoost.Apply(guaranteeCount), cap); + } + + private static (bool useGuarantee, int minimumGrade, int guaranteeCount) GetBaseGuaranteeSettings( SummonSheet.Row summonRow, int summonCount) { @@ -106,19 +155,22 @@ public static (bool useGuarantee, int minimumGrade, int guaranteeCount) GetGuara /// Random number generator /// Equipment item sheet to check grades /// Equipment item recipe sheet to map recipe IDs to equipment IDs + /// Guarantee count adjustment, or null for none. /// List of recipe IDs with grade guarantee applied public static List GetSummonRecipeIdsWithGradeGuarantee( SummonSheet.Row summonRow, int summonCount, IRandom random, EquipmentItemSheet equipmentItemSheet, - EquipmentItemRecipeSheet equipmentItemRecipeSheet) + EquipmentItemRecipeSheet equipmentItemRecipeSheet, + BoostScheduleSheet.Row guaranteeBoost = null) { var result = new List(); var guaranteedCount = 0; // Get guarantee settings - var (useGuarantee, minimumGrade, guaranteeCount) = GetGuaranteeSettings(summonRow, summonCount); + var (useGuarantee, minimumGrade, guaranteeCount) = + GetGuaranteeSettings(summonRow, summonCount, guaranteeBoost); // Process each item one by one to maintain random call order for (var i = 0; i < summonCount; i++) @@ -160,19 +212,22 @@ public static List GetSummonRecipeIdsWithGradeGuarantee( /// Random number generator /// Costume item sheet to check grades /// Equipment item recipe sheet (not used for costumes, can be null) + /// Guarantee count adjustment, or null for none. /// List of recipe IDs with grade guarantee applied public static List GetSummonRecipeIdsWithGradeGuarantee( SummonSheet.Row summonRow, int summonCount, IRandom random, CostumeItemSheet costumeItemSheet, - EquipmentItemRecipeSheet equipmentItemRecipeSheet) + EquipmentItemRecipeSheet equipmentItemRecipeSheet, + BoostScheduleSheet.Row guaranteeBoost = null) { var result = new List(); var guaranteedCount = 0; // Get guarantee settings - var (useGuarantee, minimumGrade, guaranteeCount) = GetGuaranteeSettings(summonRow, summonCount); + var (useGuarantee, minimumGrade, guaranteeCount) = + GetGuaranteeSettings(summonRow, summonCount, guaranteeBoost); // Process each item one by one to maintain random call order for (var i = 0; i < summonCount; i++) diff --git a/Lib9c/TableCSV/BoostScheduleSheet.csv b/Lib9c/TableCSV/BoostScheduleSheet.csv new file mode 100644 index 00000000000..fba62ae9b3a --- /dev/null +++ b/Lib9c/TableCSV/BoostScheduleSheet.csv @@ -0,0 +1 @@ +id,target,target_id,op,value,start_block,end_block,_memo diff --git a/Lib9c/TableCSV/BoostScheduleSheet.csv.meta b/Lib9c/TableCSV/BoostScheduleSheet.csv.meta new file mode 100644 index 00000000000..5fc652e3c77 --- /dev/null +++ b/Lib9c/TableCSV/BoostScheduleSheet.csv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e640453f5fd04cd6bca729f9dec91f1c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs new file mode 100644 index 00000000000..3e13e37782b --- /dev/null +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -0,0 +1,511 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using static Nekoyume.TableData.TableExtensions; + +namespace Nekoyume.TableData +{ + /// + /// Schedules a temporary adjustment of a game value — a reward amount, a cost, a guarantee + /// count — for a block range, so that an event can be run with patch_table_sheet + /// instead of rewriting the value's own sheet and patching it back afterwards. + /// + /// Columns: id, target, target_id, op, value, + /// start_block, end_block. A _memo column, like any _ prefixed + /// column, is dropped on load and may be used for notes. + /// + /// + /// target names what is adjusted and is matched as a plain string by the call site that + /// owns it, so the sheet itself never needs to know which targets exist. A node that predates a + /// target simply finds no match for it instead of failing to read the sheet. + /// lists the targets this version applies. + /// + /// + /// A row applies in [start_block, end_block). When several rows match, the one with the + /// lowest id wins; rows are never combined, so overlapping rows cannot stack into a + /// larger adjustment than any single row declares. + /// + /// + /// Reading is total: a row that does not parse is kept but marked invalid and never matches, + /// so a typo fails toward "no adjustment" instead of aborting every action that reads the + /// sheet. rejects such a row at patch time so it should not reach + /// the chain in the first place. + /// + /// + /// Callers must tolerate the sheet being absent — it reaches an existing chain only by + /// patch_table_sheet. Absence means no adjustment, which is also what keeps + /// re-evaluation of pre-patch blocks identical. + /// + /// + [Serializable] + public class BoostScheduleSheet : Sheet + { + /// + /// The target_id cell that matches every id of a target. + /// + public const string AnyTargetId = "*"; + + /// + /// Upper bound of |value|. Keeps every inside + /// range so that applying a row can never throw. + /// + public const decimal MaxAbsoluteValue = int.MaxValue; + + /// + /// Column names in their expected order, after _ prefixed columns are dropped. + /// + public static readonly string[] ColumnNames = + { + "id", + "target", + "target_id", + "op", + "value", + "start_block", + "end_block", + }; + + /// + /// The targets this version applies. A row naming any other target is valid data but has + /// no effect until a version that applies it is running. + /// + public static class Targets + { + /// + /// Guarantee count of an EquipmentSummonSheet group. target_id is the + /// group id. + /// + public const string EquipmentSummonGuarantee = "EQUIPMENT_SUMMON_GUARANTEE"; + + /// + /// Guarantee count of a RuneSummonSheet group. target_id is the group id. + /// + public const string RuneSummonGuarantee = "RUNE_SUMMON_GUARANTEE"; + + /// + /// Guarantee count of a CostumeSummonSheet group. target_id is the + /// group id. + /// + public const string CostumeSummonGuarantee = "COSTUME_SUMMON_GUARANTEE"; + + /// + /// Every target above, for tooling that warns about rows no running code applies. + /// + public static readonly IReadOnlyCollection Applied = new[] + { + EquipmentSummonGuarantee, + RuneSummonGuarantee, + CostumeSummonGuarantee, + }; + } + + /// + /// How a row's is combined with the value it adjusts. + /// + public enum Operation + { + /// + /// floor(base × value). + /// + Mul, + + /// + /// base + value. value must be an integer. + /// + Add, + } + + /// + /// One scheduled adjustment. + /// + [Serializable] + public class Row : SheetRow + { + /// + public override int Key => Id; + + /// + /// The row id. Also the precedence among overlapping rows: lower wins. + /// + public int Id { get; private set; } + + /// + /// What this row adjusts. See . + /// + public string Target { get; private set; } = string.Empty; + + /// + /// The id within this row applies to, or null for all. + /// + public int? TargetId { get; private set; } + + /// + /// How is combined with the adjusted value. + /// + public Operation Op { get; private set; } + + /// + /// The operand of . + /// + public decimal Value { get; private set; } + + /// + /// First block index this row applies to. + /// + public long StartBlockIndex { get; private set; } + + /// + /// First block index this row no longer applies to. + /// + public long EndBlockIndex { get; private set; } + + /// + /// Whether every cell parsed. An invalid row never matches. + /// + public bool IsValid { get; private set; } + + /// + /// Reads one row. + /// + /// The row's cells, with "_" prefixed columns already dropped. + /// + /// Total by design: does not guard this call, so a + /// throw here would fail every action that reads the sheet. + /// + public override void Set(IReadOnlyList fields) + { + Id = Cell(fields, 0) is { } idCell && TryParseInt(idCell, out var id) ? id : 0; + Target = Cell(fields, 1) ?? string.Empty; + IsValid = TryParseRest(fields, out var targetId, out var op, out var value, + out var start, out var end); + TargetId = targetId; + Op = op; + Value = value; + StartBlockIndex = start; + EndBlockIndex = end; + IsValid = IsValid && Id > 0 && Target.Length > 0; + } + + /// + /// Whether this row applies to of + /// at . + /// + /// The target being adjusted. + /// The id within the target. + /// The block being evaluated. + /// true when the row is valid and covers all three. + public bool Matches(string target, int targetId, long blockIndex) => + IsValid && + string.Equals(Target, target, StringComparison.Ordinal) && + (TargetId is null || TargetId == targetId) && + StartBlockIndex <= blockIndex && + blockIndex < EndBlockIndex; + + /// + /// Applies this row to , rounding down and clamping the + /// result to [0, int.MaxValue]. + /// + /// The value before adjustment. + /// The adjusted value. + /// + /// Rounding down means a small base can be left unchanged by a multiplier — e.g. + /// 1 × 1.5 stays 1. Use when every target + /// must actually move. + /// + public int Apply(int baseValue) + { + var result = Op == Operation.Add + ? baseValue + Value + : baseValue * Value; + result = decimal.Floor(result); + if (result <= 0m) + { + return 0; + } + + return result >= int.MaxValue ? int.MaxValue : (int)result; + } + + private static bool TryParseRest( + IReadOnlyList fields, + out int? targetId, + out Operation op, + out decimal value, + out long start, + out long end) + { + targetId = null; + op = default; + value = default; + start = default; + end = default; + + var targetIdCell = Cell(fields, 2); + if (targetIdCell is null) + { + return false; + } + + if (targetIdCell != AnyTargetId) + { + if (!TryParseInt(targetIdCell, out var parsedTargetId) || parsedTargetId <= 0) + { + return false; + } + + targetId = parsedTargetId; + } + + if (!TryParseOperation(Cell(fields, 3), out op) || + !(Cell(fields, 4) is { } valueCell && TryParseDecimal(valueCell, out value)) || + !(Cell(fields, 5) is { } startCell && TryParseLong(startCell, out start)) || + !(Cell(fields, 6) is { } endCell && TryParseLong(endCell, out end))) + { + return false; + } + + return IsValueValid(op, value) && 0 <= start && start < end; + } + } + + /// + /// Creates an empty sheet. + /// + public BoostScheduleSheet() : base(nameof(BoostScheduleSheet)) + { + } + + /// + /// Finds the row that applies to of + /// at . + /// + /// The target being adjusted. See . + /// The id within the target. + /// The block being evaluated. + /// The matching row with the lowest id, or null when none applies. + public Row FindActive(string target, int targetId, long blockIndex) + { + if (OrderedList is null) + { + return null; + } + + foreach (var row in OrderedList) + { + if (row.Matches(target, targetId, blockIndex)) + { + return row; + } + } + + return null; + } + + /// + /// Adds a parsed row to the sheet. + /// + /// The row's key. + /// The row to add. + /// + /// A row without a usable id is dropped and a duplicated id keeps the first row instead of + /// throwing, because a throw here would fail every action that reads the sheet. + /// + protected override void AddRow(int key, Row value) + { + if (key <= 0 || ContainsKey(key)) + { + return; + } + + base.AddRow(key, value); + } + + /// + /// Parses strictly so that a malformed schedule fails the + /// patch_table_sheet transaction instead of landing on the chain as a row that + /// silently never applies. + /// + /// The sheet's CSV, as the patch would write it. + /// + /// The header does not match , or a row has an unusable or + /// duplicated id, an empty target, an unparsable cell, a value out of range for its + /// operation, or a block range that is empty. + /// + /// + /// Whether a target is applied by any running code is deliberately not checked here: the + /// set of targets grows with releases, and a patch must not be accepted by one node version + /// and rejected by another. Compare against off-chain. + /// + public static void ValidateCsv(string csv) + { + var lines = csv.Trim().Split('\n'); + + // Mirror Sheet.Set: it drops "_" prefixed columns from every line + // based on the header, so validating raw fields would judge different columns than + // the ones the runtime reads. + var droppedColumns = new HashSet(); + var headerFields = lines[0].Trim().Split(','); + for (var i = 0; i < headerFields.Length; i++) + { + if (headerFields[i].StartsWith("_")) + { + droppedColumns.Add(i); + } + } + + var columnNames = Select(headerFields, droppedColumns); + if (!columnNames.SequenceEqual(ColumnNames)) + { + throw new SheetRowValidateException( + $"header must be \"{string.Join(",", ColumnNames)}\"" + + $" but was \"{string.Join(",", columnNames)}\"."); + } + + // Sheet.Set skips any line starting with "," or "_", so a dropped column in front + // would make every row whose memo is empty (or starts with "_") vanish. + if (droppedColumns.Contains(0)) + { + throw new SheetRowValidateException( + $"the first column must be \"{ColumnNames[0]}\"; put \"_\" columns after it."); + } + + var ids = new HashSet(); + var lineNumber = 1; + foreach (var rawLine in lines.Skip(1)) + { + lineNumber++; + + // Sheet.Set skips these lines, so a row written there would never apply. Only a + // blank line — what a spreadsheet export leaves as ",,,,,," — may be skipped. + if (rawLine.Trim().Trim(',').Trim().Length == 0) + { + continue; + } + + if (rawLine.StartsWith(",") || rawLine.StartsWith("_")) + { + throw new SheetRowValidateException( + $"line {lineNumber}: starts with \"{rawLine[0]}\" so the sheet would skip" + + " it. Is the id missing?"); + } + + var fields = Select(rawLine.Trim().Split(','), droppedColumns); + if (fields.Count != ColumnNames.Length) + { + throw new SheetRowValidateException( + $"line {lineNumber}: expected {ColumnNames.Length} cells" + + $" but found {fields.Count}. A \"_\" column cannot contain a comma."); + } + + var row = new Row(); + row.Set(fields); + if (row.Id <= 0) + { + throw new SheetRowValidateException( + $"line {lineNumber}: id({fields[0].Trim()}) must be a positive integer."); + } + + if (!ids.Add(row.Id)) + { + throw new SheetRowValidateException( + $"line {lineNumber}: duplicated id({row.Id})."); + } + + if (!row.IsValid) + { + throw new SheetRowValidateException( + $"line {lineNumber}: {Describe(fields)}"); + } + } + + // Guards against this validator and Sheet.Set disagreeing about which rows exist. + var sheet = new BoostScheduleSheet(); + sheet.Set(csv); + if (sheet.Count != ids.Count) + { + throw new SheetRowValidateException( + $"validated {ids.Count} row(s) but the sheet parsed {sheet.Count}."); + } + } + + private static string Describe(IReadOnlyList fields) + { + var target = fields[1].Trim(); + if (target.Length == 0) + { + return "target is empty."; + } + + var targetId = fields[2].Trim(); + if (targetId != AnyTargetId && + !(TryParseInt(targetId, out var parsedTargetId) && parsedTargetId > 0)) + { + return $"target_id({targetId}) must be a positive integer or \"{AnyTargetId}\"."; + } + + if (!TryParseOperation(fields[3].Trim(), out var op)) + { + return $"op({fields[3].Trim()}) must be MUL or ADD."; + } + + if (!TryParseDecimal(fields[4].Trim(), out var value) || !IsValueValid(op, value)) + { + return op == Operation.Add + ? $"value({fields[4].Trim()}) must be an integer for ADD" + + $" and at most {MaxAbsoluteValue} in magnitude." + : $"value({fields[4].Trim()}) must be greater than 0 for MUL" + + $" and at most {MaxAbsoluteValue}."; + } + + if (!TryParseLong(fields[5].Trim(), out var start) || start < 0) + { + return $"start_block({fields[5].Trim()}) must be a non-negative integer."; + } + + if (!TryParseLong(fields[6].Trim(), out var end) || end <= start) + { + return $"end_block({fields[6].Trim()}) must be an integer greater than" + + $" start_block({start})."; + } + + return "row is invalid."; + } + + private static bool TryParseOperation(string cell, out Operation op) + { + switch (cell) + { + case "MUL": + op = Operation.Mul; + return true; + case "ADD": + op = Operation.Add; + return true; + default: + op = default; + return false; + } + } + + private static bool IsValueValid(Operation op, decimal value) => + op == Operation.Add + ? decimal.Truncate(value) == value && Math.Abs(value) <= MaxAbsoluteValue + : value > 0m && value <= MaxAbsoluteValue; + + private static string Cell(IReadOnlyList fields, int index) + { + if (fields.Count <= index) + { + return null; + } + + var cell = fields[index].Trim(); + return cell.Length == 0 ? null : cell; + } + + private static IReadOnlyList Select( + IReadOnlyList fields, + ICollection droppedColumns) => + fields.Where((_, index) => !droppedColumns.Contains(index)).ToList(); + } +} From 14414f1707e38c6e96eb5d8ed45e78cb034f0220 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Thu, 8 Oct 2026 14:15:31 +0900 Subject: [PATCH 23/41] =?UTF-8?q?feat:=20BoostScheduleSheet=20target=5Fid?= =?UTF-8?q?=20=EC=97=90=20=EA=B5=AC=EA=B0=84=20=EB=AC=B8=EB=B2=95(1~50)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 등급·레벨 구간별로 배율이 다른 조정(예: 룬 강화 lv1~50 ×0.6, lv51~100 ×0.65)을 id 마다 행을 쓰지 않고 구간 하나로 표현한다. 구분자를 "-" 대신 "~" 로 한 건 스프레드시트가 "1-12" 를 날짜로 바꿔버리는 걸 피하기 위해서다. Co-Authored-By: Claude Opus 5.5 --- .../TableData/BoostScheduleSheetTest.cs | 44 ++++++++- Lib9c/TableData/BoostScheduleSheet.cs | 92 +++++++++++++------ 2 files changed, 106 insertions(+), 30 deletions(-) diff --git a/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs b/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs index 15b7f1332b1..e2332a9746c 100644 --- a/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs +++ b/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs @@ -34,7 +34,8 @@ public void ParsesRow() var row = sheet[1]; Assert.True(row.IsValid); Assert.Equal(Target, row.Target); - Assert.Equal(10001, row.TargetId); + Assert.Equal(10001, row.TargetIdBegin); + Assert.Equal(10001, row.TargetIdEnd); Assert.Equal(BoostScheduleSheet.Operation.Mul, row.Op); Assert.Equal(1.5m, row.Value); Assert.Equal(100, row.StartBlockIndex); @@ -57,15 +58,52 @@ public void AnyTargetIdMatchesEveryId() { var sheet = Parse($"1,{Target},*,ADD,1,100,200\n"); - Assert.Null(sheet[1].TargetId); + Assert.Null(sheet[1].TargetIdBegin); + Assert.Null(sheet[1].TargetIdEnd); Assert.NotNull(sheet.FindActive(Target, 10001, 150)); Assert.NotNull(sheet.FindActive(Target, 99999, 150)); } + [Theory] + [InlineData(0, false)] + [InlineData(1, true)] // both ends are inclusive + [InlineData(25, true)] + [InlineData(50, true)] + [InlineData(51, false)] + public void TargetIdRangeIsInclusive(int targetId, bool expected) + { + var sheet = Parse($"1,{Target},1~50,MUL,0.6,100,200\n"); + + Assert.Equal(1, sheet[1].TargetIdBegin); + Assert.Equal(50, sheet[1].TargetIdEnd); + Assert.Equal(expected, sheet.FindActive(Target, targetId, 150) is not null); + } + + [Fact] + public void TargetIdRangesSplitATargetIntoTiers() + { + // How a tiered discount is written: one row per tier instead of one per id. + var sheet = Parse( + $"1,{Target},1~50,MUL,0.6,100,200\n" + + $"2,{Target},51~100,MUL,0.65,100,200\n" + + $"3,{Target},101~200,MUL,0.75,100,200\n"); + + Assert.Equal(0.6m, sheet.FindActive(Target, 50, 150).Value); + Assert.Equal(0.65m, sheet.FindActive(Target, 51, 150).Value); + Assert.Equal(0.75m, sheet.FindActive(Target, 200, 150).Value); + Assert.Null(sheet.FindActive(Target, 201, 150)); + } + [Theory] [InlineData("1,,10001,MUL,1.5,100,200")] // empty target [InlineData("1,TARGET,abc,MUL,1.5,100,200")] // target_id not a number [InlineData("1,TARGET,0,MUL,1.5,100,200")] // target_id not positive + [InlineData("1,TARGET,50~1,MUL,1.5,100,200")] // reversed range + [InlineData("1,TARGET,0~50,MUL,1.5,100,200")] // range starting at 0 + [InlineData("1,TARGET,1~,MUL,1.5,100,200")] // open range + [InlineData("1,TARGET,~50,MUL,1.5,100,200")] + [InlineData("1,TARGET,1~20~50,MUL,1.5,100,200")] + [InlineData("1,TARGET,1-50,MUL,1.5,100,200")] // "-" is not the separator [InlineData("1,TARGET,10001,POW,1.5,100,200")] // unknown op [InlineData("1,TARGET,10001,mul,1.5,100,200")] // op is case sensitive [InlineData("1,TARGET,10001,MUL,1.5x,100,200")] // value not a number @@ -197,6 +235,7 @@ public void ValidateCsvAcceptsWellFormedRows() "id,target,target_id,op,value,start_block,end_block,_memo\n" + $"1,{Target},10001,MUL,1.5,100,200,memo\n" + $"2,{Target},*,ADD,-1,0,1,\n" + + $"4,{Target},1~50,MUL,0.6,0,1,\n" + "3,SOME_FUTURE_TARGET,1,MUL,2,100,200,\n"); } @@ -245,6 +284,7 @@ public void ValidateCsvRejectsADroppedColumnInFront() [InlineData(Header + "1,T,10001,ADD,1,100,200\n1,T,10001,ADD,1,100,200\n")] // duplicated id [InlineData(Header + "1,,10001,ADD,1,100,200\n")] // empty target [InlineData(Header + "1,T,x,ADD,1,100,200\n")] // target_id + [InlineData(Header + "1,T,50~1,ADD,1,100,200\n")] // target_id range [InlineData(Header + "1,T,10001,SET,1,100,200\n")] // op [InlineData(Header + "1,T,10001,MUL,0,100,200\n")] // value [InlineData(Header + "1,T,10001,ADD,1.5,100,200\n")] // value diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index 3e13e37782b..46333fc6ff4 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -15,6 +15,11 @@ namespace Nekoyume.TableData /// column, is dropped on load and may be used for notes. /// /// + /// target_id is a single id (50), an inclusive range (1~50) or + /// * for every id. ~ rather than - keeps a spreadsheet from reading a + /// range as a date on its way to the CSV. + /// + /// /// target names what is adjusted and is matched as a plain string by the call site that /// owns it, so the sheet itself never needs to know which targets exist. A node that predates a /// target simply finds no match for it instead of failing to read the sheet. @@ -45,6 +50,11 @@ public class BoostScheduleSheet : Sheet /// public const string AnyTargetId = "*"; + /// + /// Separates the two ends of a target_id range, as in 1~50. + /// + public const char TargetIdRangeSeparator = '~'; + /// /// Upper bound of |value|. Keeps every inside /// range so that applying a row can never throw. @@ -135,9 +145,15 @@ public class Row : SheetRow public string Target { get; private set; } = string.Empty; /// - /// The id within this row applies to, or null for all. + /// First id within this row applies to, or null for all. + /// + public int? TargetIdBegin { get; private set; } + + /// + /// Last id within this row applies to, inclusive, or + /// null for all. Equals for a single id. /// - public int? TargetId { get; private set; } + public int? TargetIdEnd { get; private set; } /// /// How is combined with the adjusted value. @@ -176,9 +192,10 @@ public override void Set(IReadOnlyList fields) { Id = Cell(fields, 0) is { } idCell && TryParseInt(idCell, out var id) ? id : 0; Target = Cell(fields, 1) ?? string.Empty; - IsValid = TryParseRest(fields, out var targetId, out var op, out var value, - out var start, out var end); - TargetId = targetId; + IsValid = TryParseRest(fields, out var targetIdBegin, out var targetIdEnd, + out var op, out var value, out var start, out var end); + TargetIdBegin = targetIdBegin; + TargetIdEnd = targetIdEnd; Op = op; Value = value; StartBlockIndex = start; @@ -197,7 +214,7 @@ public override void Set(IReadOnlyList fields) public bool Matches(string target, int targetId, long blockIndex) => IsValid && string.Equals(Target, target, StringComparison.Ordinal) && - (TargetId is null || TargetId == targetId) && + (TargetIdBegin is null || (TargetIdBegin <= targetId && targetId <= TargetIdEnd)) && StartBlockIndex <= blockIndex && blockIndex < EndBlockIndex; @@ -228,35 +245,22 @@ public int Apply(int baseValue) private static bool TryParseRest( IReadOnlyList fields, - out int? targetId, + out int? targetIdBegin, + out int? targetIdEnd, out Operation op, out decimal value, out long start, out long end) { - targetId = null; + targetIdBegin = null; + targetIdEnd = null; op = default; value = default; start = default; end = default; - var targetIdCell = Cell(fields, 2); - if (targetIdCell is null) - { - return false; - } - - if (targetIdCell != AnyTargetId) - { - if (!TryParseInt(targetIdCell, out var parsedTargetId) || parsedTargetId <= 0) - { - return false; - } - - targetId = parsedTargetId; - } - - if (!TryParseOperation(Cell(fields, 3), out op) || + if (!TryParseTargetIds(Cell(fields, 2), out targetIdBegin, out targetIdEnd) || + !TryParseOperation(Cell(fields, 3), out op) || !(Cell(fields, 4) is { } valueCell && TryParseDecimal(valueCell, out value)) || !(Cell(fields, 5) is { } startCell && TryParseLong(startCell, out start)) || !(Cell(fields, 6) is { } endCell && TryParseLong(endCell, out end))) @@ -437,10 +441,11 @@ private static string Describe(IReadOnlyList fields) } var targetId = fields[2].Trim(); - if (targetId != AnyTargetId && - !(TryParseInt(targetId, out var parsedTargetId) && parsedTargetId > 0)) + if (!TryParseTargetIds(targetId, out _, out _)) { - return $"target_id({targetId}) must be a positive integer or \"{AnyTargetId}\"."; + return $"target_id({targetId}) must be a positive integer, a range such as" + + $" \"1{TargetIdRangeSeparator}50\" whose ends are positive and in order," + + $" or \"{AnyTargetId}\"."; } if (!TryParseOperation(fields[3].Trim(), out var op)) @@ -471,6 +476,37 @@ private static string Describe(IReadOnlyList fields) return "row is invalid."; } + private static bool TryParseTargetIds(string cell, out int? begin, out int? end) + { + begin = null; + end = null; + if (cell is null) + { + return false; + } + + if (cell == AnyTargetId) + { + return true; + } + + var ends = cell.Split(TargetIdRangeSeparator); + if (ends.Length > 2 || + !TryParsePositiveInt(ends[0], out var parsedBegin) || + !TryParsePositiveInt(ends[ends.Length - 1], out var parsedEnd) || + parsedBegin > parsedEnd) + { + return false; + } + + begin = parsedBegin; + end = parsedEnd; + return true; + } + + private static bool TryParsePositiveInt(string cell, out int value) => + TryParseInt(cell.Trim(), out value) && value > 0; + private static bool TryParseOperation(string cell, out Operation op) { switch (cell) From 9d5845a7ab1336f12678dcd825c75a6fa19d6126 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Thu, 8 Oct 2026 14:26:32 +0900 Subject: [PATCH 24/41] =?UTF-8?q?feat:=20BoostScheduleSheet=20=EB=A1=9C=20?= =?UTF-8?q?=ED=95=A9=EC=84=B1=20=EC=9E=AC=EB=A3=8C=20=EC=88=98=EB=9F=89=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로테이션 이벤트 E1(합성 재료 할인)을 SynthesizeSheet 를 덮어쓰지 않고 BoostScheduleSheet 행으로 기간 한정 적용한다. - target SYNTHESIZE_REQUIRED_COUNT, target_id = 재료 등급(grade_id). 구간(1~4)으로 등급 티어별 배율을 표현한다 - item_sub_type 은 구분하지 않는다. 기획이 같은 등급이면 Aura/Grimoire/FullCostume 동일 배율을 원하므로 target 하나로 충분하고, 구분이 필요해지면 target 을 추가한다 - required_count 를 쓰는 곳은 SynthesizeSimulator.Simulate 한 곳뿐이라 거기서 GetRequiredCount 로 적용한다. Synthesize.Execute 가 TryGetPatchedSheet 로 행을 찾아 InputData.RequiredCountBoost 로 넘긴다 (기본 null, 기존 호출부 그대로 컴파일·동작) - 내림 후 최소 1로 바닥을 둔다: 할인이 합성을 공짜로 만들거나 0 나눗셈으로 터지지 않게. 시트 값이 0 이하이면 부스트 없이 원래대로 실패하게 그대로 둔다 - 시트 미패치·매칭 없음이면 소모 재료·난수 소비·결과가 이전과 동일 Co-Authored-By: Claude Opus 5.5 --- .Lib9c.Tests/Action/SynthesizeTest.cs | 148 ++++++++++++++++++ .../SynthesizeSimulatorRequiredCountTest.cs | 106 +++++++++++++ Lib9c/Action/Synthesize.cs | 9 ++ Lib9c/Helper/SynthesizeSimulator.cs | 43 ++++- Lib9c/TableData/BoostScheduleSheet.cs | 17 ++ 5 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 .Lib9c.Tests/Helper/SynthesizeSimulatorRequiredCountTest.cs diff --git a/.Lib9c.Tests/Action/SynthesizeTest.cs b/.Lib9c.Tests/Action/SynthesizeTest.cs index 6fc0affceb4..8cbe5d7c9d8 100644 --- a/.Lib9c.Tests/Action/SynthesizeTest.cs +++ b/.Lib9c.Tests/Action/SynthesizeTest.cs @@ -481,6 +481,150 @@ public void ExecuteWithoutRestrictionSheet() Assert.Single(state.GetInventoryV2(avatarAddress).Items); } + /// + /// A row changes how many + /// materials one synthesis consumes, but only inside its block range. Grade 4 auras need 11; + /// the schedule makes it 6, so 12 materials synthesize twice in range and do not divide + /// outside it. + /// + /// The executed block, relative to the schedule's first block. + /// Whether that block is inside the schedule. + [Theory] + [InlineData(0, true)] + [InlineData(9, true)] + [InlineData(-1, false)] + [InlineData(10, false)] + public void Execute_AppliesScheduledRequiredCountBoost(long offset, bool scheduled) + { + const Grade grade = Grade.Unique; + const ItemSubType itemSubType = ItemSubType.Aura; + const int materialCount = 12; + Assert.Equal(11, GetSucceededMaterialCount(itemSubType, grade)); + + var state = Init(out var agentAddress, out var avatarAddress, out var start); + (state, var items) = UpdateItemsFromSubType( + grade, + GetSubTypeArray(itemSubType, materialCount), + state, + avatarAddress); + var csv = RequiredCountBoostCsv((int)grade, start); + state = state + .SetActionPoint(avatarAddress, 120) + .SetLegacyState(Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)), csv.Serialize()); + + var action = new Synthesize + { + AvatarAddress = avatarAddress, + MaterialIds = SynthesizeSimulator.GetItemGuids(items), + ChargeAp = false, + MaterialGradeId = (int)grade, + MaterialItemSubTypeId = (int)itemSubType, + }; + var ctx = new ActionContext + { + BlockIndex = start + offset, + PreviousState = state, + RandomSeed = 0, + Signer = agentAddress, + }; + + if (!scheduled) + { + Assert.Throws(() => action.Execute(ctx)); + return; + } + + var nextState = action.Execute(ctx); + + var boostScheduleSheet = new BoostScheduleSheet(); + boostScheduleSheet.Set(csv); + var boost = boostScheduleSheet.FindActive( + BoostScheduleSheet.Targets.SynthesizeRequiredCount, + (int)grade, + ctx.BlockIndex); + Assert.NotNull(boost); + var expected = SynthesizeSimulator.Simulate(new SynthesizeSimulator.InputData + { + Grade = grade, + ItemSubType = itemSubType, + MaterialCount = materialCount, + SynthesizeSheet = TableSheets.SynthesizeSheet, + SynthesizeWeightSheet = TableSheets.SynthesizeWeightSheet, + CostumeItemSheet = TableSheets.CostumeItemSheet, + EquipmentItemSheet = TableSheets.EquipmentItemSheet, + EquipmentItemRecipeSheet = TableSheets.EquipmentItemRecipeSheet, + EquipmentItemSubRecipeSheetV2 = TableSheets.EquipmentItemSubRecipeSheetV2, + EquipmentItemOptionSheet = TableSheets.EquipmentItemOptionSheet, + SkillSheet = TableSheets.SkillSheet, + BlockIndex = ctx.BlockIndex, + RandomObject = new TestRandom(0), + RequiredCountBoost = boost, + }); + Assert.Equal(2, expected.Count); + + var actual = nextState.GetInventoryV2(avatarAddress).Items.Select(i => i.item).ToList(); + Assert.Equal( + SynthesizeSimulator.GetItemGuids(expected.Select(r => r.ItemBase)).OrderBy(id => id), + SynthesizeSimulator.GetItemGuids(actual).OrderBy(id => id)); + } + + /// + /// A chain never patched with , one patched with an empty + /// sheet, and one whose rows miss this grade or this block all synthesize exactly as before + /// the sheet existed: same materials consumed, same draws, same items. + /// + [Fact] + public void Execute_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet() + { + const Grade grade = Grade.Unique; + const ItemSubType itemSubType = ItemSubType.Aura; + var target = BoostScheduleSheet.Targets.SynthesizeRequiredCount; + var materialCount = GetSucceededMaterialCount(itemSubType, grade) * 2; + + var state = Init(out var agentAddress, out var avatarAddress, out var blockIndex); + (state, var items) = UpdateItemsFromSubType( + grade, + GetSubTypeArray(itemSubType, materialCount), + state, + avatarAddress); + state = state.SetActionPoint(avatarAddress, 120); + + var sheetAddress = Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)); + var missingCsv = + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{target},5~9,MUL,0.5,{blockIndex},{blockIndex + 10}\n" + + $"2,{target},{(int)grade},MUL,0.5,{blockIndex + 1},{blockIndex + 10}\n"; + IValue[] sheets = + { + Null.Value, + "id,target,target_id,op,value,start_block,end_block\n".Serialize(), + missingCsv.Serialize(), + }; + + var action = new Synthesize + { + AvatarAddress = avatarAddress, + MaterialIds = SynthesizeSimulator.GetItemGuids(items), + ChargeAp = false, + MaterialGradeId = (int)grade, + MaterialItemSubTypeId = (int)itemSubType, + }; + var inventories = sheets + .Select(sheet => action.Execute( + new ActionContext + { + BlockIndex = blockIndex, + PreviousState = state.SetLegacyState(sheetAddress, sheet), + RandomSeed = 0, + Signer = agentAddress, + })) + .Select(nextState => nextState.GetInventoryV2(avatarAddress)) + .ToList(); + + Assert.Equal(2, inventories[0].Items.Count); + Assert.All(inventories, inventory => Assert.Equal(inventories[0].Serialize(), inventory.Serialize())); + } + /// /// A result pool is what the weight sheet spells out. An item it does not list is not drawn, /// so adding one to an item sheet no longer puts it into circulation on its own. @@ -805,4 +949,8 @@ private static int GetSucceededMaterialCount(ItemSubType itemSubType, Grade grad var row = synthesizeSheet.Values.First(r => (Grade)r.GradeId == grade); return row.RequiredCountDict[itemSubType].RequiredCount; } + + private static string RequiredCountBoostCsv(int grade, long start) => + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.SynthesizeRequiredCount},1~{grade},MUL,0.6,{start},{start + 10}\n"; } diff --git a/.Lib9c.Tests/Helper/SynthesizeSimulatorRequiredCountTest.cs b/.Lib9c.Tests/Helper/SynthesizeSimulatorRequiredCountTest.cs new file mode 100644 index 00000000000..b70275ef7a5 --- /dev/null +++ b/.Lib9c.Tests/Helper/SynthesizeSimulatorRequiredCountTest.cs @@ -0,0 +1,106 @@ +namespace Lib9c.Tests.Helper; + +using Nekoyume.Helper; +using Nekoyume.Model.Item; +using Nekoyume.TableData; +using Xunit; + +/// +/// : how a +/// row changes the number of +/// materials one synthesis consumes. +/// +public class SynthesizeSimulatorRequiredCountTest +{ + private const string Header = "id,target,target_id,op,value,start_block,end_block\n"; + + private static readonly string Target = BoostScheduleSheet.Targets.SynthesizeRequiredCount; + + // The tiers planning asked for: G1-G4 x0.6, G5-G6 x0.7, G7 x0.8, nothing above. + private static readonly string TieredCsv = + Header + + $"1,{Target},1~4,MUL,0.6,100,200\n" + + $"2,{Target},5~6,MUL,0.7,100,200\n" + + $"3,{Target},7,MUL,0.8,100,200\n"; + + [Theory] + [InlineData(1)] + [InlineData(13)] + [InlineData(0)] + [InlineData(-1)] + public void WithoutABoostTheConfiguredCountIsKept(int requiredCount) + { + Assert.Equal(requiredCount, SynthesizeSimulator.GetRequiredCount(requiredCount, null)); + } + + [Theory] + [InlineData("MUL,0.6", 13, 7)] // 7.8 rounds down + [InlineData("MUL,0.6", 10, 6)] + [InlineData("MUL,2", 9, 18)] + [InlineData("ADD,-3", 11, 8)] + [InlineData("ADD,5", 4, 9)] + public void AppliesTheRow(string opAndValue, int requiredCount, int expected) + { + var row = Row($"1,{Target},*,{opAndValue},100,200"); + + Assert.Equal(expected, SynthesizeSimulator.GetRequiredCount(requiredCount, row)); + } + + [Theory] + [InlineData("MUL,0.6", 1)] + [InlineData("MUL,0.1", 4)] + [InlineData("ADD,-100", 4)] + public void NeverDropsBelowOne(string opAndValue, int requiredCount) + { + var row = Row($"1,{Target},*,{opAndValue},100,200"); + + Assert.Equal(1, SynthesizeSimulator.GetRequiredCount(requiredCount, row)); + } + + [Theory] + [InlineData(0)] + [InlineData(-2)] + public void LeavesANonPositiveCountAlone(int requiredCount) + { + var row = Row($"1,{Target},*,ADD,5,100,200"); + + Assert.Equal(requiredCount, SynthesizeSimulator.GetRequiredCount(requiredCount, row)); + } + + /// + /// The live under the tiers planning asked for, one row per + /// grade range, applied to every sub type of a grade alike. + /// + /// The material grade id. + /// The material sub type. + /// The count one synthesis consumes during the event. + [Theory] + [InlineData(1, ItemSubType.Aura, 7)] // 13 x 0.6 + [InlineData(3, ItemSubType.FullCostume, 10)] // 18 x 0.6 + [InlineData(4, ItemSubType.Grimoire, 6)] // 11 x 0.6 + [InlineData(5, ItemSubType.Aura, 8)] // 12 x 0.7 + [InlineData(6, ItemSubType.FullCostume, 7)] // 11 x 0.7 + [InlineData(7, ItemSubType.Grimoire, 7)] // 9 x 0.8 + [InlineData(8, ItemSubType.Aura, 4)] // no row + [InlineData(9, ItemSubType.Aura, 1)] // no row + public void TiersByGradeRange(int grade, ItemSubType itemSubType, int expected) + { + var synthesizeSheet = new TableSheets(TableSheetsImporter.ImportSheets()).SynthesizeSheet; + var boostScheduleSheet = new BoostScheduleSheet(); + boostScheduleSheet.Set(TieredCsv); + + var row = boostScheduleSheet.FindActive(Target, grade, 150); + var requiredCount = synthesizeSheet[grade].RequiredCountDict[itemSubType].RequiredCount; + + Assert.Equal(expected, SynthesizeSimulator.GetRequiredCount(requiredCount, row)); + } + + private static BoostScheduleSheet.Row Row(string line) + { + var sheet = new BoostScheduleSheet(); + sheet.Set(Header + line + "\n"); + var row = Assert.Single(sheet.Values); + Assert.True(row.IsValid); + return row; + } +} diff --git a/Lib9c/Action/Synthesize.cs b/Lib9c/Action/Synthesize.cs index 083670f013b..5c63bfc7d70 100644 --- a/Lib9c/Action/Synthesize.cs +++ b/Lib9c/Action/Synthesize.cs @@ -189,6 +189,14 @@ public override IWorld Execute(IActionContext context) } }; + // Absent until the sheet is patched onto this chain, in which case the required count is + // the one SynthesizeSheet configures, exactly as before the sheet existed. + states.TryGetPatchedSheet(out var boostScheduleSheet); + var requiredCountBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.SynthesizeRequiredCount, + MaterialGradeId, + context.BlockIndex); + var synthesizedItems = SynthesizeSimulator.Simulate(new SynthesizeSimulator.InputData() { Grade = materialGrade, @@ -204,6 +212,7 @@ public override IWorld Execute(IActionContext context) SkillSheet = sheets.GetSheet(), BlockIndex = context.BlockIndex, RandomObject = context.GetRandom(), + RequiredCountBoost = requiredCountBoost, }); // Add synthesized items to inventory diff --git a/Lib9c/Helper/SynthesizeSimulator.cs b/Lib9c/Helper/SynthesizeSimulator.cs index e69153276c6..6d18ebc8d40 100644 --- a/Lib9c/Helper/SynthesizeSimulator.cs +++ b/Lib9c/Helper/SynthesizeSimulator.cs @@ -97,6 +97,13 @@ public struct InputData /// Caution: Must have the same seed as when the action is executed /// public IRandom RandomObject; + /// + /// The row adjusting the required material count of + /// at , or null for none. + /// See . + /// Caution: Must be the row the action resolved, or the material count may not divide. + /// + public BoostScheduleSheet.Row? RequiredCountBoost; } private struct EquipmentData @@ -138,7 +145,9 @@ public static List Simulate(InputData inputData) var itemSubType = inputData.ItemSubType; var materialCount = inputData.MaterialCount; - var requiredCount = synthesizeRow.RequiredCountDict[itemSubType].RequiredCount; + var requiredCount = GetRequiredCount( + synthesizeRow.RequiredCountDict[itemSubType].RequiredCount, + inputData.RequiredCountBoost); var succeedRate = synthesizeRow.RequiredCountDict[itemSubType].SucceedRate; var synthesizeCount = materialCount / requiredCount; var remainder = materialCount % requiredCount; @@ -193,6 +202,38 @@ public static List Simulate(InputData inputData) return synthesizeResults; } + /// + /// Returns how many materials one synthesis consumes, after a + /// adjustment. + /// + /// + /// The required_count that configures. + /// + /// + /// The row found for and + /// the material grade at the current block, or null for none. + /// + /// + /// itself when there is no row or it is not positive; + /// otherwise the adjusted count, rounded down but never below 1. + /// + /// + /// The floor of 1 keeps a discount from making a synthesis free (and the division by the + /// count from failing): 1 × 0.6 stays 1. A count the sheet configures as 0 + /// or less is left alone so that it fails exactly as it did without the boost. + /// + public static int GetRequiredCount( + int requiredCount, + BoostScheduleSheet.Row? requiredCountBoost) + { + if (requiredCountBoost is null || requiredCount <= 0) + { + return requiredCount; + } + + return Math.Max(1, requiredCountBoost.Apply(requiredCount)); + } + public static List GetMaterialList( List materialIds, AvatarState avatarState, long blockIndex, Grade grade, ItemSubType itemSubType, diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index 46333fc6ff4..412a2af15f0 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -98,6 +98,22 @@ public static class Targets /// public const string CostumeSummonGuarantee = "COSTUME_SUMMON_GUARANTEE"; + /// + /// required_count of a SynthesizeSheet grade — how many materials one + /// synthesis consumes. target_id is the material grade id (grade_id, + /// e.g. 1~4), and a row applies to every item_sub_type of that grade + /// alike. The adjusted count never drops below 1 — which keeps synthesis from being + /// free, not from being cheap: a typo such as MUL 0.06 brings every grade down + /// to 1, so keep values within the range planning intended. + /// + /// Patch a row only after a client that picks materials by the adjusted count is + /// released: the action still requires the material count to be a multiple of it, so + /// a client choosing by the configured count fails every synthesis while the row is + /// active. + /// + /// + public const string SynthesizeRequiredCount = "SYNTHESIZE_REQUIRED_COUNT"; + /// /// Every target above, for tooling that warns about rows no running code applies. /// @@ -106,6 +122,7 @@ public static class Targets EquipmentSummonGuarantee, RuneSummonGuarantee, CostumeSummonGuarantee, + SynthesizeRequiredCount, }; } From 67e2e9064820cfee936060b60fea53fa79d61cc6 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Thu, 8 Oct 2026 14:34:20 +0900 Subject: [PATCH 25/41] =?UTF-8?q?feat:=20BoostScheduleSheet=20=EB=A1=9C=20?= =?UTF-8?q?=EC=9B=94=EB=93=9C=EB=B3=B4=EC=8A=A4=20=EB=B3=B4=EC=83=81=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 월드보스 보상(룬·크리스탈·서클)을 BoostScheduleSheet 행으로 블록 구간 동안 조정한다. 기념일 "월드보스 보상 x2" 같은 이벤트를 원본 보상 시트를 고치지 않고 걸기 위함. - target 을 보상 출처별로 나눈다: WORLD_BOSS_BATTLE_REWARD(Raid 전투 보상), WORLD_BOSS_KILL_REWARD(Raid 안의 처치 보상 + ClaimWordBossKillReward), WORLD_BOSS_RANK_REWARD(ClaimRaidReward 등급 보상). target_id 는 boss_id(900001 등). 처치·등급 보상은 지급(청구) 블록 기준이라 이벤트 전에 쌓아 둔 미청구분도 이벤트 중 청구하면 부스트된다. 전투 보상만 걸고 싶을 때를 위해 출처별로 분리했다. - 뽑기 횟수가 아니라 뽑힌 결과를 스케일한다. 룬 개수 루프·SetRune 은 그대로라 IRandom 소비가 부스트 유무와 무관하게 동일하고, 같은 액션에서 뒤이어 뽑는 보상도 바뀌지 않는다. MUL 2 는 뽑힌 룬 종류별 개수·크리스탈·서클을 각각 정확히 2배로 만든다. ADD 는 각 수량에 따로 더해진다(룬은 뽑힌 종류마다). 0 이 된 수량은 지급하지 않고, 원래 0 인 보상(전투 보상의 크리스탈 등)을 새로 만들지 않는다. - WorldBossHelper.CalculateReward, RaidSimulator 생성자, SetWorldBossKillReward 에 기본값 null 인 선택 인자로 넘긴다. 클라이언트가 같은 값을 넘겨야 재시뮬레이션 결과가 맞는다. - 시트가 패치된 적 없거나 매칭 행이 없으면 기존과 동일(재실행 동일). - 기여도 보상(ClaimWorldBossReward)과 랭킹 보상(WorldBossRankingRewardSheet, 체인 밖 지급)은 이번 범위에서 제외. Co-Authored-By: Claude Opus 5.5 --- .Lib9c.Tests/Action/ClaimRaidRewardTest.cs | 106 +++++++++++ .../Action/ClaimWorldBossKillRewardTest.cs | 82 ++++++++ .Lib9c.Tests/Action/RaidTest.cs | 178 +++++++++++------- .Lib9c.Tests/Action/WorldBossHelperTest.cs | 142 ++++++++++++++ Lib9c/Action/ClaimRaidReward.cs | 8 +- Lib9c/Action/ClaimWordBossKillReward.cs | 8 +- Lib9c/Action/Raid.cs | 16 +- Lib9c/Battle/RaidSimulator.cs | 12 +- Lib9c/Helper/WorldBossHelper.cs | 69 ++++++- Lib9c/Module/LegacyModule.cs | 8 +- Lib9c/TableData/BoostScheduleSheet.cs | 30 +++ 11 files changed, 582 insertions(+), 77 deletions(-) diff --git a/.Lib9c.Tests/Action/ClaimRaidRewardTest.cs b/.Lib9c.Tests/Action/ClaimRaidRewardTest.cs index cdfdfb9a6b6..854112e7dc0 100644 --- a/.Lib9c.Tests/Action/ClaimRaidRewardTest.cs +++ b/.Lib9c.Tests/Action/ClaimRaidRewardTest.cs @@ -11,6 +11,7 @@ namespace Lib9c.Tests.Action using Nekoyume.Model.Item; using Nekoyume.Model.State; using Nekoyume.Module; + using Nekoyume.TableData; using Xunit; public class ClaimRaidRewardTest @@ -147,5 +148,110 @@ public void Execute(Type exc, int rank, int latestRank) })); } } + + [Theory] + [InlineData(5_000_000L, 5_100_000L, 2)] // inside the schedule + [InlineData(5_000_000L, 5_055_201L, 1)] // the schedule ended at the claim block + [InlineData(5_055_202L, 5_100_000L, 1)] // the schedule has not started yet + public void Execute_AppliesScheduledRankRewardBoost( + long startBlock, + long endBlock, + int expectedMultiplier) + { + var bossRow = _tableSheets.WorldBossListSheet.OrderedList.First(); + var csv = + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.WorldBossRankReward},{bossRow.BossId},MUL,2," + + $"{startBlock},{endBlock}\n" + + $"2,{BoostScheduleSheet.Targets.WorldBossKillReward},*,MUL,3,0,{long.MaxValue}\n" + + $"3,{BoostScheduleSheet.Targets.WorldBossRankReward},{bossRow.BossId + 1},MUL,5,0," + + $"{long.MaxValue}\n"; + + AssertRankReward(csv.Serialize(), expectedMultiplier); + } + + [Fact] + public void Execute_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet() + { + AssertRankReward(Bencodex.Types.Null.Value, 1); + } + + private void AssertRankReward(Bencodex.Types.IValue boostScheduleSheet, int multiplier) + { + const int rank = 5; + const long blockIndex = 5055201L; + const int randomSeed = 0; + Address agentAddress = default; + var avatarAddress = new PrivateKey().Address; + var bossRow = _tableSheets.WorldBossListSheet.OrderedList.First(); + var raiderAddress = Addresses.GetRaiderAddress(avatarAddress, bossRow.Id); + var characterRow = _tableSheets.WorldBossCharacterSheet[bossRow.BossId]; + var highScore = characterRow.WaveStats + .Where(w => w.Wave <= rank) + .Sum(w => (long)w.HP); + var avatarState = AvatarState.Create( + avatarAddress, + agentAddress, + 0, + _tableSheets.GetAvatarSheets(), + default + ); + var state = _state + .SetLegacyState( + Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)), + boostScheduleSheet) + .SetLegacyState(raiderAddress, new RaiderState { HighScore = highScore }.Serialize()) + .SetAvatarState(avatarAddress, avatarState); + + var nextState = new ClaimRaidReward(avatarAddress).Execute( + new ActionContext + { + Signer = agentAddress, + BlockIndex = blockIndex, + RandomSeed = randomSeed, + PreviousState = state, + }); + + // What the unboosted claim grants, drawn with the same random. + var random = new TestRandom(randomSeed); + var circleRow = _tableSheets.MaterialItemSheet.Values + .First(r => r.ItemSubType == ItemSubType.Circle); + var expectedCircle = 0; + var inventory = nextState.GetAvatarState(avatarAddress).inventory; + var balances = new System.Collections.Generic.Dictionary< + Libplanet.Types.Assets.Currency, Libplanet.Types.Assets.FungibleAssetValue>(); + for (var i = 0; i < rank; i++) + { + var (assets, materials) = WorldBossHelper.CalculateReward( + i + 1, + bossRow.BossId, + _tableSheets.RuneWeightSheet, + _tableSheets.WorldBossRankRewardSheet, + _tableSheets.RuneSheet, + _tableSheets.MaterialItemSheet, + random); + foreach (var asset in assets) + { + balances[asset.Currency] = balances.TryGetValue(asset.Currency, out var sum) + ? sum + asset + : asset; + } + + expectedCircle += materials.Values.Sum(); + } + + Assert.NotEmpty(balances); + foreach (var (currency, expected) in balances) + { + var owner = currency.Equals(CrystalCalculator.CRYSTAL) ? agentAddress : avatarAddress; + Assert.Equal(expected * multiplier, nextState.GetBalance(owner, currency)); + } + + var circle = inventory.TryGetTradableFungibleItems( + circleRow.ItemId, null, blockIndex, out var items) + ? items.Sum(item => item.count) + : 0; + Assert.Equal(expectedCircle * multiplier, circle); + } } } diff --git a/.Lib9c.Tests/Action/ClaimWorldBossKillRewardTest.cs b/.Lib9c.Tests/Action/ClaimWorldBossKillRewardTest.cs index a402cac2458..4515b5bd8a6 100644 --- a/.Lib9c.Tests/Action/ClaimWorldBossKillRewardTest.cs +++ b/.Lib9c.Tests/Action/ClaimWorldBossKillRewardTest.cs @@ -147,5 +147,87 @@ public void Execute(long blockIndex, Type exc) })); } } + + [Theory] + [InlineData(150L, 3)] // inside [100, 200) + [InlineData(200L, 1)] // the schedule has ended + [InlineData(99L, 1)] // the schedule has not started yet + public void Execute_AppliesScheduledKillRewardBoost(long blockIndex, int multiplier) + { + var csv = + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.WorldBossKillReward},900001,MUL,3,100,200\n" + + $"2,{BoostScheduleSheet.Targets.WorldBossRankReward},*,MUL,5,0,1000\n"; + + AssertKillReward(csv.Serialize(), blockIndex, multiplier); + } + + [Fact] + public void Execute_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet() + { + AssertKillReward(Null.Value, 150L, 1); + } + + private static void AssertKillReward(IValue boostScheduleSheet, long blockIndex, int multiplier) + { + var sheets = TableSheetsImporter.ImportSheets(); + var tableSheets = new TableSheets(sheets); + var agentAddress = new PrivateKey().Address; + var avatarAddress = new PrivateKey().Address; + var runeWeightSheet = new RuneWeightSheet(); + runeWeightSheet.Set("id,boss_id,rank,rune_id,weight\n1,900001,0,10001,100\n"); + var killRewardSheet = new WorldBossKillRewardSheet(); + killRewardSheet.Set("id,boss_id,rank,rune_min,rune_max,crystal\n1,900001,0,1,1,100\n"); + var worldBossListSheet = new WorldBossListSheet(); + worldBossListSheet.Set( + "id,boss_id,started_block_index,ended_block_index,fee,ticket_price," + + "additional_ticket_price,max_purchase_count\n1,900001,0,100,300,200,100,10\n"); + var rewardRecord = new WorldBossKillRewardRecord + { + [0] = false, + [1] = false, + }; + var worldBossState = new WorldBossState( + worldBossListSheet[1], + tableSheets.WorldBossGlobalHpSheet[1]) + { + Level = 2, + }; + var avatarState = AvatarState.Create( + avatarAddress, + agentAddress, + 0, + tableSheets.GetAvatarSheets(), + avatarAddress.Derive("ranking_map")); + IWorld state = new World(MockUtil.MockModernWorldState) + .SetLegacyState(Addresses.GetSheetAddress(), runeWeightSheet.Serialize()) + .SetLegacyState(Addresses.GetSheetAddress(), worldBossListSheet.Serialize()) + .SetLegacyState(Addresses.GetSheetAddress(), killRewardSheet.Serialize()) + .SetLegacyState(Addresses.GetSheetAddress(), tableSheets.RuneSheet.Serialize()) + .SetLegacyState(Addresses.GetSheetAddress(), tableSheets.WorldBossCharacterSheet.Serialize()) + .SetLegacyState(Addresses.GetSheetAddress(), tableSheets.MaterialItemSheet.Serialize()) + .SetLegacyState(Addresses.GetSheetAddress(), boostScheduleSheet) + .SetLegacyState(Addresses.GameConfig, new GameConfigState(sheets[nameof(GameConfigSheet)]).Serialize()) + .SetAvatarState(avatarAddress, avatarState) + .SetLegacyState(Addresses.GetWorldBossKillRewardRecordAddress(avatarAddress, 1), rewardRecord.Serialize()) + .SetLegacyState(Addresses.GetWorldBossAddress(1), worldBossState.Serialize()) + .SetLegacyState(Addresses.GetRaiderAddress(avatarAddress, 1), new RaiderState().Serialize()); + + var nextState = new ClaimWordBossKillReward { AvatarAddress = avatarAddress }.Execute( + new ActionContext + { + BlockIndex = blockIndex, + Signer = agentAddress, + PreviousState = state, + RandomSeed = 0, + }); + + // Two unclaimed levels, each granting 1 rune and 100 crystal before the boost. + var runeCurrency = RuneHelper.ToCurrency(tableSheets.RuneSheet[10001]); + Assert.Equal(2 * multiplier * runeCurrency, nextState.GetBalance(avatarAddress, runeCurrency)); + Assert.Equal( + 2 * 100 * multiplier * CrystalCalculator.CRYSTAL, + nextState.GetBalance(agentAddress, CrystalCalculator.CRYSTAL)); + } } } diff --git a/.Lib9c.Tests/Action/RaidTest.cs b/.Lib9c.Tests/Action/RaidTest.cs index d082b0131c1..21863fff24c 100644 --- a/.Lib9c.Tests/Action/RaidTest.cs +++ b/.Lib9c.Tests/Action/RaidTest.cs @@ -473,6 +473,105 @@ int runeId2 [Fact] public void Execute_With_Reward() + { + ExecuteWithReward(null, 1, 1); + } + + [Theory] + [InlineData(0L, 2, 3)] // both schedules cover the raid's block + [InlineData(1L, 1, 1)] // both schedules end at the raid's block + [InlineData(-1L, 1, 1)] // both schedules start right after the raid's block + public void Execute_With_Reward_AppliesScheduledBoost( + long offset, + int battleMultiplier, + int killMultiplier) + { + // The raid runs at StartedBlockIndex + WorldBossRequiredInterval of the first season. + var bossRow = _tableSheets.WorldBossListSheet.First().Value; + var gameConfigState = new GameConfigState(_sheets[nameof(GameConfigSheet)]); + var raidBlock = bossRow.StartedBlockIndex + gameConfigState.WorldBossRequiredInterval; + var (start, end) = offset switch + { + 0L => (raidBlock, raidBlock + 1), + 1L => (raidBlock - 1, raidBlock), + _ => (raidBlock + 1, raidBlock + 2), + }; + var csv = + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.WorldBossBattleReward},{bossRow.BossId},MUL,2,{start},{end}\n" + + $"2,{BoostScheduleSheet.Targets.WorldBossKillReward},{bossRow.BossId},MUL,3,{start},{end}\n" + + $"3,{BoostScheduleSheet.Targets.WorldBossRankReward},*,MUL,5,0,{long.MaxValue}\n"; + + ExecuteWithReward(csv.Serialize(), battleMultiplier, killMultiplier); + } + + [Fact] + public void Execute_With_Reward_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet() + { + ExecuteWithReward(Null.Value, 1, 1); + } + + [Fact] + public void Execute_With_Free_Crystal_Fee() + { + var action = new Raid + { + AvatarAddress = _avatarAddress, + EquipmentIds = new List(), + CostumeIds = new List(), + FoodIds = new List(), + RuneInfos = new List(), + PayNcg = false, + }; + var crystal = CrystalCalculator.CRYSTAL; + + _sheets[nameof(WorldBossListSheet)] = + "id,boss_id,started_block_index,ended_block_index,fee,ticket_price,additional_ticket_price,max_purchase_count\r\n" + + "1,900002,0,100,0,1,1,40"; + + var goldCurrencyState = new GoldCurrencyState(_goldCurrency); + var state = new World(MockUtil.MockModernWorldState) + .SetLegacyState(goldCurrencyState.address, goldCurrencyState.Serialize()) + .SetAgentState(_agentAddress, new AgentState(_agentAddress)); + + foreach (var (key, value) in _sheets) + { + state = state.SetLegacyState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + + var gameConfigState = new GameConfigState(_sheets[nameof(GameConfigSheet)]); + var avatarState = AvatarState.Create( + _avatarAddress, + _agentAddress, + 0, + _tableSheets.GetAvatarSheets(), + default + ); + + for (var i = 0; i < 50; i++) + { + avatarState.worldInformation.ClearStage(1, i + 1, 0, _tableSheets.WorldSheet, _tableSheets.WorldUnlockSheet); + } + + state = state + .SetAvatarState(_avatarAddress, avatarState) + .SetLegacyState(gameConfigState.address, gameConfigState.Serialize()); + + var blockIndex = gameConfigState.WorldBossRequiredInterval; + var randomSeed = 0; + var ctx = new ActionContext + { + BlockIndex = blockIndex, + PreviousState = state, + RandomSeed = randomSeed, + Signer = _agentAddress, + }; + action.Execute(ctx); + } + + // boostScheduleSheet: the BoostScheduleSheet state to set, or null to keep the imported one. + // battleMultiplier / killMultiplier: how many times the unboosted reward is expected. + private void ExecuteWithReward(IValue boostScheduleSheet, int battleMultiplier, int killMultiplier) { var action = new Raid { @@ -544,6 +643,13 @@ public void Execute_With_Reward() Level = 2, }; state = state.SetLegacyState(bossAddress, bossState.Serialize()); + if (boostScheduleSheet is not null) + { + state = state.SetLegacyState( + Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)), + boostScheduleSheet); + } + var randomSeed = 0; var random = new TestRandom(randomSeed); @@ -562,10 +668,12 @@ public void Execute_With_Reward() ); simulator.Simulate(); + // The simulator and the kill reward below run unboosted: a boost only scales what is + // drawn, so the unboosted draws times the multiplier are what the action must grant. var rewardMap = new Dictionary(); foreach (var reward in simulator.AssetReward) { - rewardMap[reward.Currency] = reward; + rewardMap[reward.Currency] = reward * battleMultiplier; } var materialRewardMap = new Dictionary(); @@ -575,7 +683,7 @@ public void Execute_With_Reward() if (reward is TradableMaterial tradableMaterial) { materialRewardMap.TryAdd(tradableMaterial, 0); - materialRewardMap[tradableMaterial]++; + materialRewardMap[tradableMaterial] += battleMultiplier; } } @@ -607,20 +715,22 @@ public void Execute_With_Reward() { if (!rewardMap.ContainsKey(reward.Currency)) { - rewardMap[reward.Currency] = reward; + rewardMap[reward.Currency] = reward * killMultiplier; } else { - rewardMap[reward.Currency] += reward; + rewardMap[reward.Currency] += reward * killMultiplier; } } foreach (var reward in killRewards.materials) { materialRewardMap.TryAdd(reward.Key, 0); - materialRewardMap[reward.Key] += reward.Value; + materialRewardMap[reward.Key] += reward.Value * killMultiplier; } + Assert.Contains(simulator.AssetReward, r => !r.Currency.Equals(CrystalCalculator.CRYSTAL)); + Assert.Contains(killRewards.assets, r => r.Currency.Equals(CrystalCalculator.CRYSTAL)); foreach (var reward in rewardMap) { if (reward.Key.Equals(CrystalCalculator.CRYSTAL)) @@ -653,63 +763,5 @@ public void Execute_With_Reward() var nextRewardInfo = new WorldBossKillRewardRecord(rawRewardInfo); Assert.True(nextRewardInfo[1]); } - - [Fact] - public void Execute_With_Free_Crystal_Fee() - { - var action = new Raid - { - AvatarAddress = _avatarAddress, - EquipmentIds = new List(), - CostumeIds = new List(), - FoodIds = new List(), - RuneInfos = new List(), - PayNcg = false, - }; - var crystal = CrystalCalculator.CRYSTAL; - - _sheets[nameof(WorldBossListSheet)] = - "id,boss_id,started_block_index,ended_block_index,fee,ticket_price,additional_ticket_price,max_purchase_count\r\n" + - "1,900002,0,100,0,1,1,40"; - - var goldCurrencyState = new GoldCurrencyState(_goldCurrency); - var state = new World(MockUtil.MockModernWorldState) - .SetLegacyState(goldCurrencyState.address, goldCurrencyState.Serialize()) - .SetAgentState(_agentAddress, new AgentState(_agentAddress)); - - foreach (var (key, value) in _sheets) - { - state = state.SetLegacyState(Addresses.TableSheet.Derive(key), value.Serialize()); - } - - var gameConfigState = new GameConfigState(_sheets[nameof(GameConfigSheet)]); - var avatarState = AvatarState.Create( - _avatarAddress, - _agentAddress, - 0, - _tableSheets.GetAvatarSheets(), - default - ); - - for (var i = 0; i < 50; i++) - { - avatarState.worldInformation.ClearStage(1, i + 1, 0, _tableSheets.WorldSheet, _tableSheets.WorldUnlockSheet); - } - - state = state - .SetAvatarState(_avatarAddress, avatarState) - .SetLegacyState(gameConfigState.address, gameConfigState.Serialize()); - - var blockIndex = gameConfigState.WorldBossRequiredInterval; - var randomSeed = 0; - var ctx = new ActionContext - { - BlockIndex = blockIndex, - PreviousState = state, - RandomSeed = randomSeed, - Signer = _agentAddress, - }; - action.Execute(ctx); - } } } diff --git a/.Lib9c.Tests/Action/WorldBossHelperTest.cs b/.Lib9c.Tests/Action/WorldBossHelperTest.cs index 81dad5cc417..13a17ce46d7 100644 --- a/.Lib9c.Tests/Action/WorldBossHelperTest.cs +++ b/.Lib9c.Tests/Action/WorldBossHelperTest.cs @@ -121,6 +121,111 @@ public void CalculateReward(Type sheetType) } } + [Theory] + [InlineData(typeof(WorldBossRankRewardSheet), "2")] + [InlineData(typeof(WorldBossKillRewardSheet), "2")] + [InlineData(typeof(WorldBossBattleRewardSheet), "2")] + [InlineData(typeof(WorldBossRankRewardSheet), "1.5")] + [InlineData(typeof(WorldBossKillRewardSheet), "0.5")] + public void CalculateReward_MultipliesEveryGrantedAmountWithoutChangingTheDraws( + Type sheetType, + string value) + { + var sheet = RewardSheet(sheetType); + var boost = CreateBoostRow("MUL", value); + var multiplier = decimal.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + foreach (var rewardRow in sheet.OrderedRows) + { + var baseRandom = new TestRandom(rewardRow.Rank); + var boostedRandom = new TestRandom(rewardRow.Rank); + var (baseAssets, baseMaterials) = Calculate(rewardRow, sheet, baseRandom, null); + var (boostedAssets, boostedMaterials) = + Calculate(rewardRow, sheet, boostedRandom, boost); + + // Every amount is floor(base x value), and one that rounds to 0 is not granted. + var expectedAssets = baseAssets + .Select(a => (a.Currency, (int)decimal.Floor((int)a.MajorUnit * multiplier))) + .Where(pair => pair.Item2 > 0) + .Select(pair => pair.Item2 * pair.Currency) + .ToList(); + Assert.Equal(expectedAssets, boostedAssets); + Assert.Equal( + baseMaterials + .Select(kv => (int)decimal.Floor(kv.Value * multiplier)) + .Where(count => count > 0), + boostedMaterials.Values); + + // The same number of draws was made, so whatever follows draws the same. + Assert.Equal(baseRandom.Next(), boostedRandom.Next()); + } + } + + [Fact] + public void CalculateReward_MulOf2DoublesEachDrawnRuneCrystalAndCircle() + { + var sheet = _tableSheets.WorldBossRankRewardSheet; + var rewardRow = sheet.OrderedRows.Last(r => r.Circle > 0 && r.Crystal > 0); + var (assets, materials) = Calculate( + rewardRow, sheet, new TestRandom(), CreateBoostRow("MUL", "2")); + + Assert.Equal( + 2 * rewardRow.Rune, + assets.Where(a => !a.Currency.Equals(_crystalCurrency)).Sum(a => (int)a.MajorUnit)); + Assert.Equal( + 2 * rewardRow.Crystal * _crystalCurrency, + assets.Single(a => a.Currency.Equals(_crystalCurrency))); + Assert.Equal(2 * rewardRow.Circle, materials.Values.Single()); + } + + [Fact] + public void CalculateReward_AddAppliesToEachAmountButNeverAddsAKindTheRowDoesNotGrant() + { + // Battle rewards grant runes only. + var sheet = _tableSheets.WorldBossBattleRewardSheet; + var rewardRow = sheet.OrderedRows.First(r => r.Crystal == 0 && r.Circle == 0); + var (baseAssets, _) = Calculate(rewardRow, sheet, new TestRandom(), null); + var (assets, materials) = Calculate( + rewardRow, sheet, new TestRandom(), CreateBoostRow("ADD", "10")); + + Assert.Equal(baseAssets.Select(a => a + 10 * a.Currency), assets); + Assert.DoesNotContain(assets, a => a.Currency.Equals(_crystalCurrency)); + Assert.Empty(materials); + } + + [Fact] + public void CalculateReward_DropsAmountsABoostTakesToZero() + { + var sheet = _tableSheets.WorldBossRankRewardSheet; + var rewardRow = sheet.OrderedRows.First(); + var (assets, materials) = Calculate( + rewardRow, sheet, new TestRandom(), CreateBoostRow("ADD", "-100000000")); + + Assert.Empty(assets); + Assert.Empty(materials); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(123)] + public void ApplyRewardBoost_WithoutBoost_IsUnchanged(int amount) + { + Assert.Equal(amount, WorldBossHelper.ApplyRewardBoost(null, amount)); + } + + [Theory] + [InlineData("MUL", "2", 0, 0)] + [InlineData("ADD", "5", 0, 0)] + [InlineData("MUL", "2", 7, 14)] + [InlineData("MUL", "1.5", 1, 1)] + [InlineData("MUL", "1.5", 3, 4)] + [InlineData("ADD", "5", 7, 12)] + [InlineData("ADD", "-10", 7, 0)] + public void ApplyRewardBoost(string op, string value, int amount, int expected) + { + Assert.Equal(expected, WorldBossHelper.ApplyRewardBoost(CreateBoostRow(op, value), amount)); + } + [Theory] [InlineData(1000, 250, "25")] [InlineData(1000000, 1, "0.0001")] @@ -166,5 +271,42 @@ public void CalculateContributionReward(decimal contribution, int count) Assert.All(items, i => Assert.Equal(count, i.count)); Assert.All(fav, asset => Assert.Equal(count * asset.Currency, asset)); } + + private static BoostScheduleSheet.Row CreateBoostRow(string op, string value) + { + var sheet = new BoostScheduleSheet(); + sheet.Set( + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.WorldBossRankReward},*,{op},{value},0,100\n"); + return sheet[1]; + } + + private IWorldBossRewardSheet RewardSheet(Type sheetType) + { + if (sheetType == typeof(WorldBossRankRewardSheet)) + { + return _tableSheets.WorldBossRankRewardSheet; + } + + return sheetType == typeof(WorldBossKillRewardSheet) + ? _tableSheets.WorldBossKillRewardSheet + : _tableSheets.WorldBossBattleRewardSheet; + } + + private (System.Collections.Generic.List assets, + System.Collections.Generic.Dictionary materials) Calculate( + IWorldBossRewardRow rewardRow, + IWorldBossRewardSheet sheet, + TestRandom random, + BoostScheduleSheet.Row boost) => + WorldBossHelper.CalculateReward( + rewardRow.Rank, + rewardRow.BossId, + _tableSheets.RuneWeightSheet, + sheet, + _tableSheets.RuneSheet, + _tableSheets.MaterialItemSheet, + random, + boost); } } diff --git a/Lib9c/Action/ClaimRaidReward.cs b/Lib9c/Action/ClaimRaidReward.cs index a8df9059976..17aaac0213c 100644 --- a/Lib9c/Action/ClaimRaidReward.cs +++ b/Lib9c/Action/ClaimRaidReward.cs @@ -65,6 +65,11 @@ public override IWorld Execute(IActionContext context) var raiderAddress = Addresses.GetRaiderAddress(AvatarAddress, raidId); RaiderState raiderState = states.GetRaiderState(raiderAddress); int rank = WorldBossHelper.CalculateRank(bossRow, raiderState.HighScore); + states.TryGetPatchedSheet(out var boostScheduleSheet); + var rankRewardBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.WorldBossRankReward, + row.BossId, + context.BlockIndex); var random = context.GetRandom(); var inventory = states.GetInventoryV2(AvatarAddress); if (raiderState.LatestRewardRank < rank) @@ -78,7 +83,8 @@ public override IWorld Execute(IActionContext context) sheets.GetSheet(), sheets.GetSheet(), sheets.GetSheet(), - random + random, + rankRewardBoost ); foreach (var reward in rewards.assets) diff --git a/Lib9c/Action/ClaimWordBossKillReward.cs b/Lib9c/Action/ClaimWordBossKillReward.cs index 133cc1c25d3..cb5b1b634a4 100644 --- a/Lib9c/Action/ClaimWordBossKillReward.cs +++ b/Lib9c/Action/ClaimWordBossKillReward.cs @@ -57,6 +57,11 @@ public override IWorld Execute(IActionContext context) var rewardRecord = new WorldBossKillRewardRecord((List) states.GetLegacyState(worldBossKillRewardRecordAddress)); Address worldBossAddress = Addresses.GetWorldBossAddress(raidId); var worldBossState = new WorldBossState((List) states.GetLegacyState(worldBossAddress)); + states.TryGetPatchedSheet(out var boostScheduleSheet); + var killRewardBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.WorldBossKillReward, + worldBossState.Id, + context.BlockIndex); var random = context.GetRandom(); var inventory = states.GetInventoryV2(AvatarAddress); return states.SetWorldBossKillReward( @@ -72,7 +77,8 @@ public override IWorld Execute(IActionContext context) random, inventory, AvatarAddress, - context.Signer + context.Signer, + killRewardBoost ); } diff --git a/Lib9c/Action/Raid.cs b/Lib9c/Action/Raid.cs index 3b9fe06084f..6a70a151967 100644 --- a/Lib9c/Action/Raid.cs +++ b/Lib9c/Action/Raid.cs @@ -239,6 +239,16 @@ public override IWorld Execute(IActionContext context) var collectionSheet = sheets.GetSheet(); collectionModifiers = collectionState.GetModifiers(collectionSheet); } + states.TryGetPatchedSheet(out var boostScheduleSheet); + var battleRewardBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.WorldBossBattleReward, + row.BossId, + context.BlockIndex); + var killRewardBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.WorldBossKillReward, + bossState.Id, + context.BlockIndex); + // Simulate. var random = context.GetRandom(); var simulator = new RaidSimulator( @@ -253,7 +263,8 @@ public override IWorld Execute(IActionContext context) collectionModifiers, sheets.GetSheet(), sheets.GetSheet(), - shatterStrikeMaxDamage: gameConfigState.ShatterStrikeMaxDamage + shatterStrikeMaxDamage: gameConfigState.ShatterStrikeMaxDamage, + battleRewardBoost: battleRewardBoost ); simulator.Simulate(); avatarState.inventory = simulator.Player.Inventory; @@ -356,7 +367,8 @@ public override IWorld Execute(IActionContext context) random, avatarState.inventory, AvatarAddress, - context.Signer + context.Signer, + killRewardBoost ); } else diff --git a/Lib9c/Battle/RaidSimulator.cs b/Lib9c/Battle/RaidSimulator.cs index 0046a801e55..0194ad2c127 100644 --- a/Lib9c/Battle/RaidSimulator.cs +++ b/Lib9c/Battle/RaidSimulator.cs @@ -30,6 +30,11 @@ public class RaidSimulator : Simulator private MaterialItemSheet _materialItemSheet; private WorldBossCharacterSheet.Row _currentBossRow; + // The BoostScheduleSheet row of BoostScheduleSheet.Targets.WorldBossBattleReward active at + // the raid's block, or null. A client replaying a raid must pass what the action resolved + // to show the same reward. + private readonly BoostScheduleSheet.Row _battleRewardBoost; + public RaidSimulator(int bossId, IRandom random, AvatarState avatarState, @@ -41,9 +46,11 @@ public RaidSimulator(int bossId, List collectionModifiers, BuffLimitSheet buffLimitSheet, BuffLinkSheet buffLinkSheet, - long shatterStrikeMaxDamage = 400_000) : base(random, avatarState, foods, simulatorSheets, + long shatterStrikeMaxDamage = 400_000, + BoostScheduleSheet.Row battleRewardBoost = null) : base(random, avatarState, foods, simulatorSheets, shatterStrikeMaxDamage: shatterStrikeMaxDamage) { + _battleRewardBoost = battleRewardBoost; BuffLimitSheet = buffLimitSheet; BuffLinkSheet = buffLinkSheet; var runeOptionSheet = simulatorSheets.RuneOptionSheet; @@ -220,7 +227,8 @@ public BattleLog Simulate() _worldBossBattleRewardSheet, _runeSheet, _materialItemSheet, - Random); + Random, + _battleRewardBoost); AssetReward = rewards.assets; var materialReward = new List(); diff --git a/Lib9c/Helper/WorldBossHelper.cs b/Lib9c/Helper/WorldBossHelper.cs index 5460eca821b..58703915a10 100644 --- a/Lib9c/Helper/WorldBossHelper.cs +++ b/Lib9c/Helper/WorldBossHelper.cs @@ -53,6 +53,30 @@ public static bool CanRefillTicket(long blockIndex, long refilledIndex, long sta (refilledIndex - startedIndex) / refillInterval; } + /// + /// Calculates a world boss reward — runes drawn by , crystal + /// and circles — for of . + /// + /// The rank whose reward row is granted. + /// The boss id, as in WorldBossListSheet.boss_id. + /// Weights the drawn runes. + /// + /// The battle, kill or rank reward sheet the reward row is taken from. + /// + /// Maps a drawn rune id to its currency. + /// Supplies the circle material. + /// Draws the rune count, for a ranged row, and each rune. + /// + /// The row adjusting this reward at the current block, or + /// null for none. A caller replaying a reward must pass what the action resolved. + /// See . + /// + /// The granted assets, runes first and crystal last, and circles. + /// + /// scales what was drawn rather than how many draws are made, + /// so is consumed exactly as without it and everything drawn after + /// this reward (e.g. the next kill reward of the same action) stays the same. + /// public static (List assets, Dictionary materials) CalculateReward( int rank, int bossId, @@ -60,7 +84,8 @@ public static (List assets, Dictionary r.Rank == rank && r.BossId == bossId); @@ -95,27 +120,59 @@ IRandom random #pragma warning disable LAA1002 var assets = dictionary #pragma warning restore LAA1002 - .Select(kv => RuneHelper.ToFungibleAssetValue(runeSheet[kv.Key], kv.Value)) + .Select(kv => (runeId: kv.Key, count: ApplyRewardBoost(rewardBoost, kv.Value))) + .Where(pair => pair.count > 0) + .Select(pair => RuneHelper.ToFungibleAssetValue(runeSheet[pair.runeId], pair.count)) .ToList(); - if (rewardRow.Crystal > 0) + var crystal = ApplyRewardBoost(rewardBoost, rewardRow.Crystal); + if (crystal > 0) { - assets.Add(rewardRow.Crystal * CrystalCalculator.CRYSTAL); + assets.Add(crystal * CrystalCalculator.CRYSTAL); } var materials = new Dictionary(); - if (rewardRow.Circle > 0) + var circle = ApplyRewardBoost(rewardBoost, rewardRow.Circle); + if (circle > 0) { var materialRow = materialSheet.Values.First(r => r.ItemSubType == ItemSubType.Circle); var material = ItemFactory.CreateTradableMaterial(materialRow); materials.TryAdd(material, 0); - materials[material] += rewardRow.Circle; + materials[material] += circle; } return (assets, materials); } + /// + /// Applies a row to one granted amount of a world boss + /// reward: the count of one drawn rune, the crystal or the circles. + /// + /// The row to apply, or null for none. + /// The amount the reward row and the draws grant. + /// + /// unchanged when there is no row or nothing is granted; + /// otherwise of it. A result of 0 means the + /// amount is not granted at all. + /// + /// + /// A boost adjusts what a reward grants but never adds a kind the row does not grant — a + /// row with no crystal stays without crystal under ADD — since a reward that exists + /// only during an event reads as one taken away when the event ends. ADD applies to + /// every amount separately, i.e. once per distinct rune drawn; use MUL to scale a + /// reward as a whole. + /// + public static int ApplyRewardBoost(BoostScheduleSheet.Row rewardBoost, int amount) + { + if (rewardBoost is null || amount <= 0) + { + return amount; + } + + return rewardBoost.Apply(amount); + } + /// /// Calculates the contribution percentage based on total damage and individual damage. /// diff --git a/Lib9c/Module/LegacyModule.cs b/Lib9c/Module/LegacyModule.cs index 381a9962216..569fd1bcbe6 100644 --- a/Lib9c/Module/LegacyModule.cs +++ b/Lib9c/Module/LegacyModule.cs @@ -96,8 +96,11 @@ public static IWorld SetWorldBossKillReward( IRandom random, Inventory inventory, Address avatarAddress, - Address agentAddress) + Address agentAddress, + BoostScheduleSheet.Row killRewardBoost = null) { + // killRewardBoost is the BoostScheduleSheet row of + // BoostScheduleSheet.Targets.WorldBossKillReward active at the granting block, or null. if (!rewardRecord.IsClaimable(bossState.Level)) { throw new InvalidClaimException(); @@ -117,7 +120,8 @@ public static IWorld SetWorldBossKillReward( worldBossKillRewardSheet, runeSheet, materialItemSheet, - random + random, + killRewardBoost ); rewardRecord[level] = true; foreach (var reward in rewards.assets) diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index 46333fc6ff4..645b6a362e3 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -98,6 +98,33 @@ public static class Targets /// public const string CostumeSummonGuarantee = "COSTUME_SUMMON_GUARANTEE"; + /// + /// Every amount — each drawn rune, crystal, circle — of the + /// WorldBossBattleRewardSheet reward a Raid grants for its battle. + /// target_id is the boss id (WorldBossListSheet.boss_id, e.g. + /// 900001), and the block is the one the raid is played in. + /// + public const string WorldBossBattleReward = "WORLD_BOSS_BATTLE_REWARD"; + + /// + /// Every amount — each drawn rune, crystal, circle — of each + /// WorldBossKillRewardSheet reward, granted either by the Raid that + /// raises the boss level or by ClaimWordBossKillReward. target_id is the + /// boss id (WorldBossListSheet.boss_id), and the block is the one the reward is + /// granted in, not the one the boss was killed in: a kill left unclaimed until the + /// event is boosted when it is claimed during the event. + /// + public const string WorldBossKillReward = "WORLD_BOSS_KILL_REWARD"; + + /// + /// Every amount — each drawn rune, crystal, circle — of each + /// WorldBossRankRewardSheet reward ClaimRaidReward grants. + /// target_id is the boss id (WorldBossListSheet.boss_id), and the block + /// is the one the reward is claimed in, not the one the rank was reached in: a rank + /// left unclaimed until the event is boosted when it is claimed during the event. + /// + public const string WorldBossRankReward = "WORLD_BOSS_RANK_REWARD"; + /// /// Every target above, for tooling that warns about rows no running code applies. /// @@ -106,6 +133,9 @@ public static class Targets EquipmentSummonGuarantee, RuneSummonGuarantee, CostumeSummonGuarantee, + WorldBossBattleReward, + WorldBossKillReward, + WorldBossRankReward, }; } From c2ed78f225a9b51087ae1d01dbca1f56831508d9 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Thu, 8 Oct 2026 14:29:01 +0900 Subject: [PATCH 26/41] =?UTF-8?q?feat:=20BoostScheduleSheet=20=EB=A1=9C=20?= =?UTF-8?q?=EB=A3=AC=20=EA=B0=95=ED=99=94=20=EB=B9=84=EC=9A=A9=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로테이션 이벤트 E3(룬 강화 비용 할인)를 BoostScheduleSheet 행으로 표현한다. 크리스탈과 룬스톤을 서로 다른 곡선으로 깎을 수 있게 target 을 둘로 나눴다. - RUNE_ENHANCEMENT_CRYSTAL_COST / RUNE_ENHANCEMENT_RUNE_STONE_COST - target_id = 그 시도가 도달하려는 룬 레벨(현재 레벨 + 1). 모든 룬 id 에 공통. 실패한 시도는 같은 레벨을 다시 시도하므로 같은 할인율로 다시 과금된다. 룬 id 별 제한은 요구가 없어 넣지 않았다(target_id 하나로는 레벨과 id 를 동시에 담을 수 없음). - 시도마다 RuneHelper.TryEnhancement 안에서 레벨별로 행을 찾는다. 그래서 1~50 / 51~100 같은 구간이 tryCount 여러 번에 걸친 강화에도 그대로 맞는다. - NCG 와 성공률은 건드리지 않는다. 난수 소비 횟수·순서도 그대로다. - 결과는 [1, base] 로 묶는다. - 최소 1: 4 × 0.2 = 0.8 → 0 처럼 내림 때문에 공짜가 되는 것을 막는다. 공짜 강화는 RuneCostSheet 에서 직접 정할 일이다. base 가 0 이면 0 유지. - 최대 base(할인 전용): 인상 요구가 없고, 잘못 넣은 행이 시도 합계를 int 범위 밖으로 밀어 음수로 감기면 과금이 통째로 건너뛰어지기 때문. - 시트가 패치된 적 없거나 매칭 행이 없으면 같은 RuneCostData 인스턴스를 그대로 쓴다 → 재실행 결과 동일. - 새 파라미터는 모두 선택적(기본 null)이라 클라이언트 등 기존 호출부는 그대로 컴파일·동작한다. 클라이언트가 비용을 표시할 때 쓰도록 ApplyEnhancementCostBoost(cost, level, sheet, blockIndex) 를 공개한다. Co-Authored-By: Claude Opus 5.5 --- .Lib9c.Tests/Action/RuneEnhancementTest.cs | 90 ++++++++++ .Lib9c.Tests/Action/RuneHelperTest.cs | 196 +++++++++++++++++++++ Lib9c/Action/RuneEnhancement.cs | 4 +- Lib9c/Helper/RuneHelper.cs | 123 ++++++++++++- Lib9c/TableData/BoostScheduleSheet.cs | 17 ++ 5 files changed, 426 insertions(+), 4 deletions(-) diff --git a/.Lib9c.Tests/Action/RuneEnhancementTest.cs b/.Lib9c.Tests/Action/RuneEnhancementTest.cs index fbdc667ac90..936207a7ec7 100644 --- a/.Lib9c.Tests/Action/RuneEnhancementTest.cs +++ b/.Lib9c.Tests/Action/RuneEnhancementTest.cs @@ -18,6 +18,13 @@ namespace Lib9c.Tests.Action public class RuneEnhancementTest { + private const string BoostCsv = + "id,target,target_id,op,value,start_block,end_block\n" + + "1,RUNE_ENHANCEMENT_CRYSTAL_COST,1~50,MUL,0.60,100,200\n" + + "2,RUNE_ENHANCEMENT_CRYSTAL_COST,51~100,MUL,0.65,100,200\n" + + "3,RUNE_ENHANCEMENT_RUNE_STONE_COST,1~50,MUL,0.70,100,200\n" + + "4,RUNE_ENHANCEMENT_RUNE_STONE_COST,51~100,MUL,0.80,100,200\n"; + private readonly Currency _goldCurrency; public RuneEnhancementTest() @@ -264,6 +271,89 @@ int seed } } + [Theory] + // Rune 10001 from level 48, four sure tries reaching 49, 50, 51 and 52: + // base 3000 stones (240 + 2400 + 180 + 180), 70000 crystal (20000 * 2 + 15000 * 2), 50 NCG. + [InlineData(150, true, 2136, 43500)] // stones * 0.7 / 0.8, crystal * 0.6 / 0.65 + [InlineData(99, true, 3000, 70000)] // before start_block + [InlineData(200, true, 3000, 70000)] // end_block is exclusive + [InlineData(150, false, 3000, 70000)] // never patched + public void Execute_AppliesScheduledCostBoost( + long blockIndex, + bool patched, + int expectedRuneCost, + int expectedCrystalCost) + { + const int runeId = 10001; + const int initialNcg = 10_000; + const int initialCrystal = 1_000_000; + const int initialRune = 10_000; + var agentAddress = new PrivateKey().Address; + var avatarAddress = new PrivateKey().Address; + var sheets = TableSheetsImporter.ImportSheets(); + var tableSheets = new TableSheets(sheets); + var agentState = new AgentState(agentAddress); + var avatarState = AvatarState.Create( + avatarAddress, + agentAddress, + 0, + tableSheets.GetAvatarSheets(), + avatarAddress.Derive("ranking_map")); + agentState.avatarAddresses.Add(0, avatarAddress); + var goldCurrencyState = new GoldCurrencyState(_goldCurrency); + var context = new ActionContext(); + var state = new World(MockUtil.MockModernWorldState) + .SetLegacyState(goldCurrencyState.address, goldCurrencyState.Serialize()) + .SetAgentState(agentAddress, agentState) + .SetAvatarState(avatarAddress, avatarState); + foreach (var (key, value) in sheets) + { + state = state.SetLegacyState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + + var boostAddress = Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)); + state = patched + ? state.SetLegacyState(boostAddress, BoostCsv.Serialize()) + : state.SetLegacyState(boostAddress, Bencodex.Types.Null.Value); + + var allRuneState = new AllRuneState(runeId); + allRuneState.GetRuneState(runeId).LevelUp(48); + state = state.SetRuneState(avatarAddress, allRuneState); + + var ncgCurrency = state.GetGoldCurrency(); + var crystalCurrency = CrystalCalculator.CRYSTAL; + var runeTicker = tableSheets.RuneSheet[runeId].Ticker; + var runeCurrency = Currency.Legacy(runeTicker, 0, null); + state = state.MintAsset(context, agentAddress, ncgCurrency * initialNcg); + state = state.MintAsset(context, agentAddress, crystalCurrency * initialCrystal); + state = state.MintAsset(context, avatarAddress, runeCurrency * initialRune); + + var action = new RuneEnhancement + { + AvatarAddress = avatarAddress, + RuneId = runeId, + TryCount = 4, + }; + var nextState = action.Execute(new ActionContext + { + BlockIndex = blockIndex, + PreviousState = state, + RandomSeed = 0, + Signer = agentAddress, + }); + + Assert.Equal(52, nextState.GetRuneState(avatarAddress, out _).GetRuneState(runeId).Level); + Assert.Equal( + (initialNcg - 50) * ncgCurrency, + nextState.GetBalance(agentAddress, ncgCurrency)); + Assert.Equal( + (initialCrystal - expectedCrystalCost) * crystalCurrency, + nextState.GetBalance(agentAddress, crystalCurrency)); + Assert.Equal( + (initialRune - expectedRuneCost) * runeCurrency, + nextState.GetBalance(avatarAddress, runeCurrency)); + } + [Fact] public void Execute_RuneCostNotFoundException() { diff --git a/.Lib9c.Tests/Action/RuneHelperTest.cs b/.Lib9c.Tests/Action/RuneHelperTest.cs index 1c2442c8699..4635e5b9d63 100644 --- a/.Lib9c.Tests/Action/RuneHelperTest.cs +++ b/.Lib9c.Tests/Action/RuneHelperTest.cs @@ -1,8 +1,10 @@ namespace Lib9c.Tests.Action { + using System; using Libplanet.Types.Assets; using Nekoyume.Helper; using Nekoyume.Model.State; + using Nekoyume.TableData; using Xunit; public class RuneHelperTest @@ -36,5 +38,199 @@ public void CalculateRuneLevelBonus(int runeLevel, int expectedBonus) ); Assert.Equal(expectedBonus, runeLevelBonus); } + + [Theory] + [InlineData("MUL", "0.7", 4, 2)] // 2.8 rounds down + [InlineData("MUL", "0.6", 20000, 12000)] + [InlineData("MUL", "0.2", 4, 1)] // 0.8 would make the try free; kept at 1 + [InlineData("MUL", "0.0001", 1, 1)] + [InlineData("ADD", "-10", 4, 1)] + [InlineData("ADD", "-1", 4, 3)] + [InlineData("MUL", "1.5", 100, 100)] // discount only + [InlineData("ADD", "2147483647", 100, 100)] + [InlineData("MUL", "0.5", 0, 0)] // a free cost stays free + [InlineData("ADD", "5", 0, 0)] // and a boost never creates one + public void ApplyEnhancementCostBoostQuantity( + string op, + string value, + int baseCost, + int expected) + { + var boost = CreateBoostSheet($"1,RUNE_ENHANCEMENT_CRYSTAL_COST,*,{op},{value},0,10")[1]; + Assert.Equal(expected, RuneHelper.ApplyEnhancementCostBoostQuantity(boost, baseCost)); + } + + [Fact] + public void ApplyEnhancementCostBoostQuantity_WithoutBoost_IsUnchanged() + { + Assert.Equal(4, RuneHelper.ApplyEnhancementCostBoostQuantity(null, 4)); + } + + [Theory] + // level, expected crystal, expected rune stone; base cost is 20000 crystal, 240 stones. + [InlineData(1, 12000, 168)] + [InlineData(50, 12000, 168)] + [InlineData(51, 13000, 192)] + [InlineData(100, 13000, 192)] + [InlineData(101, 15000, 216)] + [InlineData(201, 16000, 240)] + [InlineData(300, 16000, 240)] + [InlineData(301, 20000, 240)] // no row covers it + public void ApplyEnhancementCostBoost_FollowsTiersByTargetLevel( + int level, + int expectedCrystal, + int expectedRuneStone) + { + var cost = new RuneCostSheet.RuneCostData(level, level, 240, 20000, 50, 7000); + + var boosted = RuneHelper.ApplyEnhancementCostBoost(cost, level, TieredSheet(), 150); + + Assert.Equal(expectedCrystal, boosted.CrystalQuantity); + Assert.Equal(expectedRuneStone, boosted.RuneStoneQuantity); + Assert.Equal(50, boosted.NcgQuantity); + Assert.Equal(7000, boosted.LevelUpSuccessRate); + Assert.Equal(level, boosted.LevelStart); + Assert.Equal(level, boosted.LevelEnd); + } + + [Theory] + [InlineData(99)] // before the schedule + [InlineData(200)] // end_block is exclusive + public void ApplyEnhancementCostBoost_OutsideTheSchedule_ReturnsTheSameCost(long blockIndex) + { + var cost = new RuneCostSheet.RuneCostData(1, 1, 240, 20000, 50, 7000); + + Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 1, TieredSheet(), blockIndex)); + Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 1, null, 150)); + } + + [Fact] + public void ApplyEnhancementCostBoost_IgnoresOtherTargets() + { + var sheet = CreateBoostSheet( + "1,RUNE_SUMMON_GUARANTEE,*,MUL,0.5,0,10", + "2,RUNE_ENHANCEMENT_NCG_COST,*,MUL,0.5,0,10"); + var cost = new RuneCostSheet.RuneCostData(1, 1, 240, 20000, 50, 7000); + + Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 1, sheet, 5)); + } + + [Fact] + public void ApplyEnhancementCostBoost_AdjustsOnlyTheScheduledCurrency() + { + var sheet = CreateBoostSheet("1,RUNE_ENHANCEMENT_RUNE_STONE_COST,1~10,MUL,0.5,0,10"); + var cost = new RuneCostSheet.RuneCostData(1, 1, 240, 20000, 50, 7000); + + var boosted = RuneHelper.ApplyEnhancementCostBoost(cost, 1, sheet, 5); + + Assert.Equal(120, boosted.RuneStoneQuantity); + Assert.Equal(20000, boosted.CrystalQuantity); + } + + [Fact] + public void TryEnhancement_AppliesBoostPerTriedLevelAndKeepsTheRandomDraws() + { + // Levels 49..52, each try succeeding 50% of the time, so a failed try repeats its + // level and must be charged that level's (boosted) cost again. + var costSheet = new RuneCostSheet(); + costSheet.Set( + "id,level_start,level_end,rune_stone_quantity,crystal_quantity,ncg_quantity,level_up_success_rate\n" + + "1,1,48,1,1,1,10000\n" + + "1,49,50,240,20000,0,5000\n" + + "1,51,52,180,15000,50,5000\n"); + var costRow = costSheet[1]; + const int seed = 7; + const int tryCount = 6; + + var baseRandom = new TestRandom(seed); + Assert.True(RuneHelper.TryEnhancement(48, costRow, baseRandom, tryCount, out var baseResult)); + var boostRandom = new TestRandom(seed); + Assert.True(RuneHelper.TryEnhancement( + 48, + costRow, + boostRandom, + tryCount, + out var boostResult, + TieredSheet(), + 150)); + + // Same draws, same outcome, same NCG. + Assert.Equal(baseRandom.Next(), boostRandom.Next()); + Assert.Equal(baseResult.LevelUpCount, boostResult.LevelUpCount); + Assert.Equal(baseResult.NcgCost, boostResult.NcgCost); + + // Recompute the expected costs try by try from the same draws. + var replay = new TestRandom(seed); + var level = 48; + var expectedCrystal = 0; + var expectedRuneStone = 0; + var failures = 0; + var highestTarget = 0; + for (var i = 0; i < tryCount; i++) + { + var target = level + 1; + highestTarget = Math.Max(highestTarget, target); + var discounted = target <= 50; + expectedCrystal += discounted ? 12000 : 9750; + expectedRuneStone += discounted ? 168 : 144; + if (replay.Next(0, 10000) < 5000) + { + level++; + } + else + { + failures++; + } + } + + // The seed must exercise both a recharged level and the 51+ tier, or the expected + // costs above prove nothing about them. + Assert.True(failures > 0, "the seed never fails a try"); + Assert.True(highestTarget > 50, $"the seed never reaches the 51+ tier ({highestTarget})"); + + Assert.Equal(expectedCrystal, boostResult.CrystalCost); + Assert.Equal(expectedRuneStone, boostResult.RuneCost); + Assert.True(boostResult.CrystalCost < baseResult.CrystalCost); + } + + [Fact] + public void TryEnhancement_WithoutBoost_IsUnchanged() + { + var costRow = _tableSheets.RuneCostSheet[10001]; + var expectedRandom = new TestRandom(3); + RuneHelper.TryEnhancement(48, costRow, expectedRandom, 4, out var expected); + + var emptyRandom = new TestRandom(3); + RuneHelper.TryEnhancement(48, costRow, emptyRandom, 4, out var withEmptySheet, new BoostScheduleSheet(), 150); + var outsideRandom = new TestRandom(3); + RuneHelper.TryEnhancement(48, costRow, outsideRandom, 4, out var outsideSchedule, TieredSheet(), 250); + + Assert.Equal(expected.ToString(), withEmptySheet.ToString()); + Assert.Equal(expected.ToString(), outsideSchedule.ToString()); + Assert.Equal(expectedRandom.Next(), emptyRandom.Next()); + } + + /// + /// The rotation event E3 curve, scheduled for blocks [100, 200). + /// + private static BoostScheduleSheet TieredSheet() => + CreateBoostSheet( + "1,RUNE_ENHANCEMENT_CRYSTAL_COST,1~50,MUL,0.60,100,200", + "2,RUNE_ENHANCEMENT_CRYSTAL_COST,51~100,MUL,0.65,100,200", + "3,RUNE_ENHANCEMENT_CRYSTAL_COST,101~200,MUL,0.75,100,200", + "4,RUNE_ENHANCEMENT_CRYSTAL_COST,201~300,MUL,0.80,100,200", + "5,RUNE_ENHANCEMENT_RUNE_STONE_COST,1~50,MUL,0.70,100,200", + "6,RUNE_ENHANCEMENT_RUNE_STONE_COST,51~100,MUL,0.80,100,200", + "7,RUNE_ENHANCEMENT_RUNE_STONE_COST,101~200,MUL,0.90,100,200", + "8,RUNE_ENHANCEMENT_RUNE_STONE_COST,201~300,MUL,1.00,100,200"); + + private static BoostScheduleSheet CreateBoostSheet(params string[] rows) + { + var sheet = new BoostScheduleSheet(); + sheet.Set( + "id,target,target_id,op,value,start_block,end_block\n" + + string.Join("\n", rows)); + return sheet; + } } } diff --git a/Lib9c/Action/RuneEnhancement.cs b/Lib9c/Action/RuneEnhancement.cs index 83334e688a1..f5767f58579 100644 --- a/Lib9c/Action/RuneEnhancement.cs +++ b/Lib9c/Action/RuneEnhancement.cs @@ -121,9 +121,11 @@ public override IWorld Execute(IActionContext context) $"[{nameof(RuneEnhancement)}] my avatar address : {AvatarAddress}"); } + states.TryGetPatchedSheet(out var boostScheduleSheet); + var random = context.GetRandom(); if (!RuneHelper.TryEnhancement(runeState.Level, costRow, random, TryCount, - out var levelUpResult)) + out var levelUpResult, boostScheduleSheet, context.BlockIndex)) { // Rune cost not found while level up throw new RuneCostDataNotFoundException( diff --git a/Lib9c/Helper/RuneHelper.cs b/Lib9c/Helper/RuneHelper.cs index 6e5965407fc..72b39e17a9d 100644 --- a/Lib9c/Helper/RuneHelper.cs +++ b/Lib9c/Helper/RuneHelper.cs @@ -30,24 +30,51 @@ public static FungibleAssetValue ToFungibleAssetValue( return Currencies.GetRune(runeRow.Ticker) * quantity; } + /// + /// Simulates enhancement tries from + /// , summing the cost of every try and drawing one random + /// number per try for its success. + /// + /// The rune level before the first try. + /// The rune's cost row. + /// The random source; one draw per try. + /// The number of tries. + /// The level-ups and the summed cost. + /// + /// The patched , or null when the chain has none. + /// Adjusts each try's crystal and rune stone cost; see + /// . + /// + /// + /// The block being evaluated, against which rows are + /// matched. Ignored when is null. + /// + /// + /// false when a try reaches a level has no cost for. + /// public static bool TryEnhancement( int startRuneLevel, RuneCostSheet.Row costRow, IRandom random, int tryCount, - out RuneEnhancement.LevelUpResult levelUpResult) + out RuneEnhancement.LevelUpResult levelUpResult, + BoostScheduleSheet? boostScheduleSheet = null, + long blockIndex = 0) { levelUpResult = new RuneEnhancement.LevelUpResult(); for (var i = 0; i < tryCount; i++) { + var targetLevel = startRuneLevel + levelUpResult.LevelUpCount + 1; + // No cost Found : throw exception at caller - if (!costRow.TryGetCost(startRuneLevel + levelUpResult.LevelUpCount + 1, - out var cost)) + if (!costRow.TryGetCost(targetLevel, out var cost)) { return false; } + cost = ApplyEnhancementCostBoost(cost, targetLevel, boostScheduleSheet, blockIndex); + // Cost burns in every try levelUpResult.NcgCost += cost.NcgQuantity; levelUpResult.CrystalCost += cost.CrystalQuantity; @@ -62,6 +89,96 @@ public static bool TryEnhancement( return true; } + /// + /// Applies the and + /// rows active at + /// to the cost of one try that reaches + /// . + /// + /// The sheet's cost for . + /// + /// The level the try attempts to reach, matched against target_id. + /// + /// + /// The patched , or null when the chain has none. + /// + /// The block being evaluated. + /// + /// itself when no row applies; otherwise a copy whose crystal and + /// rune stone quantities are adjusted by + /// . NCG quantity and + /// success rate are always copied unchanged. + /// + /// + /// Callers showing a cost — or a maximum try count — should pass every per-level cost + /// through this so that they agree with . + /// + public static RuneCostSheet.RuneCostData ApplyEnhancementCostBoost( + RuneCostSheet.RuneCostData cost, + int targetLevel, + BoostScheduleSheet? boostScheduleSheet, + long blockIndex) + { + if (boostScheduleSheet is null) + { + return cost; + } + + var crystalBoost = boostScheduleSheet.FindActive( + BoostScheduleSheet.Targets.RuneEnhancementCrystalCost, + targetLevel, + blockIndex); + var runeStoneBoost = boostScheduleSheet.FindActive( + BoostScheduleSheet.Targets.RuneEnhancementRuneStoneCost, + targetLevel, + blockIndex); + if (crystalBoost is null && runeStoneBoost is null) + { + return cost; + } + + return new RuneCostSheet.RuneCostData( + cost.LevelStart, + cost.LevelEnd, + ApplyEnhancementCostBoostQuantity(runeStoneBoost, cost.RuneStoneQuantity), + ApplyEnhancementCostBoostQuantity(crystalBoost, cost.CrystalQuantity), + cost.NcgQuantity, + cost.LevelUpSuccessRate); + } + + /// + /// Applies one row to one enhancement cost quantity. + /// + /// The row to apply, or null for none. + /// The sheet's quantity. + /// + /// The adjusted quantity, kept within [1, baseCost] when + /// is positive. A non-positive is returned unchanged. + /// + /// + /// + /// Discount only: a row can never raise a cost. Raising one is not an event anyone has + /// asked for, and it would let a mistyped row push the per-try sum in + /// past , where it wraps negative + /// and the cost is skipped instead of charged. + /// + /// + /// At least 1: rounding down a small cost (4 stones × 0.2 = 0.8) would otherwise make the + /// try free, turning a discount into a giveaway that the row never declared. A free + /// enhancement has to be configured in itself. A cost the + /// sheet already sets to 0 stays 0, so a boost never creates a cost either. + /// + /// + public static int ApplyEnhancementCostBoostQuantity(BoostScheduleSheet.Row? boost, int baseCost) + { + if (boost is null || baseCost <= 0) + { + return baseCost; + } + + return Math.Min(Math.Max(boost.Apply(baseCost), 1), baseCost); + } + public static FungibleAssetValue CalculateStakeReward(FungibleAssetValue stakeAmount, int rate) { diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index 46333fc6ff4..45d8d0cfe76 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -98,6 +98,21 @@ public static class Targets /// public const string CostumeSummonGuarantee = "COSTUME_SUMMON_GUARANTEE"; + /// + /// Crystal cost of one RuneEnhancement try, per RuneCostSheet level. + /// target_id is the rune level the try attempts to reach — the current level + /// plus one — for every rune id, so 1~50 discounts the tries from level 0 up to + /// level 50. The adjusted cost never exceeds the sheet's cost and never drops below 1 + /// while the sheet's cost is positive. NCG cost and success rate are not adjustable. + /// + public const string RuneEnhancementCrystalCost = "RUNE_ENHANCEMENT_CRYSTAL_COST"; + + /// + /// Rune stone cost of one RuneEnhancement try, per RuneCostSheet level. + /// target_id and bounds as in . + /// + public const string RuneEnhancementRuneStoneCost = "RUNE_ENHANCEMENT_RUNE_STONE_COST"; + /// /// Every target above, for tooling that warns about rows no running code applies. /// @@ -106,6 +121,8 @@ public static class Targets EquipmentSummonGuarantee, RuneSummonGuarantee, CostumeSummonGuarantee, + RuneEnhancementCrystalCost, + RuneEnhancementRuneStoneCost, }; } From 8fb0afee62b3ba0b79fbf1b1f709590199540b40 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Thu, 8 Oct 2026 14:28:56 +0900 Subject: [PATCH 27/41] =?UTF-8?q?feat:=20BoostScheduleSheet=20=EB=A1=9C=20?= =?UTF-8?q?=EC=96=B4=EB=93=9C=EB=B2=A4=EC=B2=98=EB=B3=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 어드벤처보스 탐험·소탕 보상을 BoostScheduleSheet 행으로 기간 한정 조정한다. - ADVENTURE_BOSS_FLOOR_REWARD: 층 클리어마다 AdventureBossFloorSheet 보상에서 뽑는 1개 항목의 수량. ExploreAdventureBoss·SweepAdventureBoss 둘 다 적용. - ADVENTURE_BOSS_FIRST_CLEAR_REWARD: ExploreAdventureBoss 의 첫 클리어 보상 (AdventureBossFloorFirstRewardSheet) 각 항목의 수량. - target_id 는 층 번호(floor, 1~20). 층 row id 가 아니다. 결정: - 이미 뽑힌 항목의 최종 수량만 Row.Apply 로 조정한다. 뽑기 순서·횟수가 그대로라 점수(보상 뒤에 뽑는 random)까지 부스트 유무와 무관하게 동일하다. - 수량 0 항목은 건드리지 않고(보상을 만들지 않음), 결과는 최소 1 로 둔다 (0 지급은 AddItem/MintAsset 에서 throw). - GetWaveRewards 는 바꾸지 않았다: 탐험에서 웨이브 보상은 시뮬레이터에만 넘어가고 인벤토리에 지급되지 않는다. - ClaimAdventureBossReward(현상금·기여 보상, NCG 포함)는 범위 밖. 시즌 종료 후 청구 시점 블록을 기준으로 할지 등 기획 결정이 필요하다. 탐험·소탕 보상에는 NCG 가 없다. - 시트가 패치된 적 없으면 TryGetPatchedSheet 로 기존 동작 그대로. Co-Authored-By: Claude Opus 5.5 --- .../AdventureBoss/ExploreAdventureBossTest.cs | 251 ++++++++++++++++++ .../AdventureBoss/SweepAdventureBossTest.cs | 219 +++++++++++++++ .../Helper/AdventureBossHelperTest.cs | 39 +++ .../AdventureBoss/ExploreAdventureBoss.cs | 27 +- .../AdventureBoss/SweepAdventureBoss.cs | 16 +- Lib9c/Helper/AdventureBossHelper.cs | 35 +++ Lib9c/TableData/BoostScheduleSheet.cs | 18 ++ 7 files changed, 592 insertions(+), 13 deletions(-) diff --git a/.Lib9c.Tests/Action/AdventureBoss/ExploreAdventureBossTest.cs b/.Lib9c.Tests/Action/AdventureBoss/ExploreAdventureBossTest.cs index 24e48909c95..1589e8f45e3 100644 --- a/.Lib9c.Tests/Action/AdventureBoss/ExploreAdventureBossTest.cs +++ b/.Lib9c.Tests/Action/AdventureBoss/ExploreAdventureBossTest.cs @@ -24,6 +24,9 @@ namespace Lib9c.Tests.Action.AdventureBoss public class ExploreAdventureBossTest { + // See UseBoostTestBoss. + private const int BoostTestAdventureBossId = 2; + private static readonly Dictionary Sheets = TableSheetsImporter.ImportSheets(); @@ -343,6 +346,254 @@ out var items } } + [Fact] + public void Execute_AppliesScheduledRewardBoost() + { + var (baseRewards, baseScore, adventureBossId) = ExploreWithBoostSchedule(null); + var firstClear = FirstClearRewards(adventureBossId, 1, 5); + var floorRewards = Combine(baseRewards, firstClear, -1); + Assert.NotEmpty(firstClear); + Assert.Contains(floorRewards, pair => pair.Value > 0); + + var (floorBoosted, floorBoostedScore, _) = ExploreWithBoostSchedule( + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFloorReward, "1~5", 0, 10)); + var (firstBoosted, firstBoostedScore, _) = ExploreWithBoostSchedule( + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFirstClearReward, "1~5", 0, 10)); + + // ×2 scales granted amounts only, so every draw — including the score drawn after + // each floor reward — stays where it was, and each target doubles only its own part. + Assert.Equal(baseScore, floorBoostedScore); + Assert.Equal(baseScore, firstBoostedScore); + Assert.Equal(Combine(baseRewards, floorRewards, 1), floorBoosted); + Assert.Equal(Combine(baseRewards, firstClear, 1), firstBoosted); + } + + [Fact] + public void Execute_MatchesFloorRewardBoostByFloorNumber() + { + var (baseRewards, baseScore, adventureBossId) = ExploreWithBoostSchedule(null); + var floorRewards = Combine(baseRewards, FirstClearRewards(adventureBossId, 1, 5), -1); + + // ×2 on a single floor grants exactly that floor's one reward entry once more. + var perFloor = new List>(); + for (var floor = 1; floor <= 5; floor++) + { + var (rewards, score, _) = ExploreWithBoostSchedule( + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFloorReward, $"{floor}", 0, 10)); + Assert.Equal(baseScore, score); + var extra = Combine(rewards, baseRewards, -1); + Assert.Single(extra); + Assert.True(extra.Single().Value > 0, $"floor {floor}"); + perFloor.Add(extra); + } + + Assert.Equal(floorRewards, perFloor.Aggregate((a, b) => Combine(a, b, 1))); + + // An inner range catches an off-by-one at either end. + var (inner, innerScore, _) = ExploreWithBoostSchedule( + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFloorReward, "2~4", 0, 10)); + Assert.Equal(baseScore, innerScore); + var expected = perFloor.Skip(1).Take(3).Aggregate(baseRewards, (a, b) => Combine(a, b, 1)); + Assert.Equal(expected, inner); + } + + [Fact] + public void Execute_MatchesFirstClearRewardBoostByFloorNumber() + { + var (baseRewards, baseScore, adventureBossId) = ExploreWithBoostSchedule(null); + var (inner, innerScore, _) = ExploreWithBoostSchedule( + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFirstClearReward, "2~4", 0, 10)); + + Assert.Equal(baseScore, innerScore); + Assert.Equal( + Combine(baseRewards, FirstClearRewards(adventureBossId, 2, 4), 1), + inner); + } + + [Theory] + [InlineData(2L, 10L)] + [InlineData(0L, 1L)] + public void Execute_IgnoresRewardBoostOutsideItsBlockRange(long start, long end) + { + var (baseRewards, baseScore, _) = ExploreWithBoostSchedule(null); + var csv = + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFloorReward, "*", start, end) + + $"2,{BoostScheduleSheet.Targets.AdventureBossFirstClearReward},*,MUL,2,{start},{end}\n"; + var (rewards, score, _) = ExploreWithBoostSchedule(csv); + + Assert.Equal(baseScore, score); + Assert.Equal(baseRewards, rewards); + } + + [Fact] + public void Execute_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet() + { + var (neverPatched, neverPatchedScore, _) = ExploreWithBoostSchedule(null); + var (emptySheet, emptySheetScore, _) = ExploreWithBoostSchedule( + "id,target,target_id,op,value,start_block,end_block\n"); + + Assert.Contains(neverPatched, pair => pair.Value > 0); + Assert.Equal(neverPatchedScore, emptySheetScore); + Assert.Equal(neverPatched, emptySheet); + } + + private static string BoostCsv(string target, string targetId, long start, long end) => + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{target},{targetId},MUL,2,{start},{end}\n"; + + // Sums or subtracts per key, leaving out keys that end at zero. + private static Dictionary Combine( + Dictionary left, + Dictionary right, + int sign) => + left.Keys.Union(right.Keys) + .Select(key => (key, value: left.GetValueOrDefault(key) + (sign * right.GetValueOrDefault(key)))) + .Where(pair => pair.value != 0) + .ToDictionary(pair => pair.key, pair => pair.value); + + private static string RewardKey(string itemType, int itemId) => + itemType switch + { + "Material" => $"item:{itemId}", + "Rune" => $"fav:{TableSheets.RuneSheet[itemId].Ticker}", + "Crystal" => "fav:CRYSTAL", + _ => throw new ArgumentException(itemType), + }; + + // First-clear rewards of floors fromFloor~toFloor (floor numbers) of the given boss. + private static Dictionary FirstClearRewards( + int adventureBossId, + int fromFloor, + int toFloor) + { + var rewards = new Dictionary(); + foreach (var floorRow in TableSheets.AdventureBossFloorSheet.Values.Where( + r => r.AdventureBossId == adventureBossId && + fromFloor <= r.Floor && r.Floor <= toFloor)) + { + var firstReward = TableSheets.AdventureBossFloorFirstRewardSheet[floorRow.Id]; + foreach (var reward in firstReward.Rewards) + { + var key = RewardKey(reward.ItemType, reward.ItemId); + rewards[key] = rewards.GetValueOrDefault(key) + reward.Amount; + } + } + + return rewards; + } + + // Makes season 1 fight AdventureBossSheet id 2, whose floor rows are numbered from 31, so + // that a boost matched against the floor row id instead of the floor number misses. + private static IWorld UseBoostTestBoss(IWorld state) + { + var floorOneRow = TableSheets.AdventureBossFloorSheet.Values + .First(row => row.AdventureBossId == BoostTestAdventureBossId && row.Floor == 1); + Assert.NotEqual(1, floorOneRow.Id); + var season = state.GetSeasonInfo(1); + season.BossId = TableSheets.AdventureBossSheet[BoostTestAdventureBossId].BossId; + return state.SetSeasonInfo(season).SetLatestAdventureBossSeason(season); + } + + // Explores floors 1~5 at block 1 with a fixed seed, with the BoostScheduleSheet given as + // CSV or, for null, a chain that was never patched with it. Returns what was granted, by + // item id and currency ticker, the explorer's score and the season's AdventureBossSheet id. + private (Dictionary Rewards, int Score, int AdventureBossId) + ExploreWithBoostSchedule( + string boostCsv) + { + var state = _initialState; + var gameConfigState = new GameConfigState(Sheets[nameof(GameConfigSheet)]); + state = state.SetLegacyState(gameConfigState.address, gameConfigState.Serialize()); + foreach (var (key, value) in Sheets) + { + state = state.SetLegacyState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + + state = state.SetLegacyState( + Addresses.GetSheetAddress(), + CollectionSheetFixture.Default.Serialize()); + state = state.SetLegacyState( + Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)), + boostCsv is null ? Null.Value : boostCsv.Serialize()); + + state = Stake(state, WantedAddress); + var apPotionRow = TableSheets.MaterialItemSheet.OrderedList + .First(row => row.ItemSubType == ItemSubType.ApStone); + var inventory = state.GetInventoryV2(TesterAvatarAddress); + inventory.AddItem(ItemFactory.CreateMaterial(apPotionRow), 20); + state = state.SetInventory(TesterAvatarAddress, inventory); + + var gameConfig = new GameConfigState(Sheets[nameof(GameConfigSheet)]); + state = new Wanted + { + Season = 1, + AvatarAddress = WantedAvatarAddress, + Bounty = gameConfig.AdventureBossMinBounty * NCG, + }.Execute( + new ActionContext + { + PreviousState = state, + Signer = WantedAddress, + BlockIndex = 0L, + RandomSeed = 1, + }); + state = UseBoostTestBoss(state); + state = state.SetExplorer( + 1, + new Explorer(TesterAvatarAddress, TesterAvatarState.name) { MaxFloor = 5, }); + var before = Granted(state); + + state = new ExploreAdventureBoss + { + Season = 1, + AvatarAddress = TesterAvatarAddress, + Costumes = new List(), + Equipments = new List(), + Foods = new List(), + RuneInfos = new List(), + }.Execute( + new ActionContext + { + PreviousState = state, + Signer = TesterAddress, + BlockIndex = 1L, + RandomSeed = 7, + }); + + var explorer = state.GetExplorer(1, TesterAvatarAddress); + Assert.Equal(5, explorer.Floor); + var bossId = state.GetLatestAdventureBossSeason().BossId; + var adventureBossId = TableSheets.AdventureBossSheet.Values + .First(row => row.BossId == bossId).Id; + Assert.Equal(BoostTestAdventureBossId, adventureBossId); + return (Combine(Granted(state), before, -1), explorer.Score, adventureBossId); + } + + private Dictionary Granted(IWorld state) + { + var granted = new Dictionary(); + foreach (var item in state.GetInventoryV2(TesterAvatarAddress).Items + .Where(i => i.item.ItemSubType != ItemSubType.ApStone)) + { + var key = $"item:{item.item.Id}"; + granted[key] = granted.GetValueOrDefault(key) + item.count; + } + + var currencies = TableSheets.RuneSheet.Values + .Select(row => Currencies.GetRune(row.Ticker)) + .Append(Currencies.Crystal); + foreach (var currency in currencies) + { + var balance = state.GetBalance(TesterAvatarAddress, currency).MajorUnit; + if (balance != 0) + { + granted[$"fav:{currency.Ticker}"] = balance; + } + } + + return granted; + } + private IWorld Stake(IWorld world, Address agentAddress) { var action = new Stake(new BigInteger(500_000), TesterAvatarAddress); diff --git a/.Lib9c.Tests/Action/AdventureBoss/SweepAdventureBossTest.cs b/.Lib9c.Tests/Action/AdventureBoss/SweepAdventureBossTest.cs index bd95b4bbfcf..0eedc73bb73 100644 --- a/.Lib9c.Tests/Action/AdventureBoss/SweepAdventureBossTest.cs +++ b/.Lib9c.Tests/Action/AdventureBoss/SweepAdventureBossTest.cs @@ -4,6 +4,7 @@ namespace Lib9c.Tests.Action.AdventureBoss using System.Collections.Generic; using System.Linq; using System.Numerics; + using Bencodex.Types; using Lib9c.Tests.Util; using Libplanet.Action.State; using Libplanet.Crypto; @@ -24,6 +25,9 @@ namespace Lib9c.Tests.Action.AdventureBoss public class SweepAdventureBossTest { + // See UseBoostTestBoss. + private const int BoostTestAdventureBossId = 2; + private static readonly Dictionary Sheets = TableSheetsImporter.ImportSheets(); @@ -260,6 +264,221 @@ public void Execute( } } + [Fact] + public void Execute_AppliesScheduledFloorRewardBoost() + { + var (baseRewards, baseScore) = SweepWithBoostSchedule(null); + var (boosted, boostedScore) = SweepWithBoostSchedule( + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFloorReward, "*", 0, 10)); + + // ×2 scales granted amounts only, so the points drawn between rewards stay the same. + Assert.Contains(baseRewards, pair => pair.Value > 0); + Assert.Equal(baseScore, boostedScore); + Assert.Equal( + baseRewards.ToDictionary(pair => pair.Key, pair => pair.Value * 2), + boosted); + } + + [Fact] + public void Execute_MatchesFloorRewardBoostByFloorNumber() + { + var (baseRewards, baseScore) = SweepWithBoostSchedule(null); + + // ×2 on a single floor grants exactly that floor's one reward entry once more, and + // the single-floor extras of all 20 floors add up to the whole sweep. + var total = new Dictionary(); + for (var floor = 1; floor <= 20; floor++) + { + var (rewards, score) = SweepWithBoostSchedule( + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFloorReward, $"{floor}", 0, 10)); + Assert.Equal(baseScore, score); + var extra = Combine(rewards, baseRewards, -1); + Assert.Single(extra); + Assert.True(extra.Single().Value > 0, $"floor {floor}"); + total = Combine(total, extra, 1); + } + + Assert.Equal(baseRewards, total); + } + + [Fact] + public void Execute_AppliesRangeBoostToThoseFloorsOnly() + { + var (baseRewards, _) = SweepWithBoostSchedule(null); + var expected = baseRewards; + for (var floor = 2; floor <= 4; floor++) + { + var (single, _) = SweepWithBoostSchedule( + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFloorReward, $"{floor}", 0, 10)); + expected = Combine(expected, Combine(single, baseRewards, -1), 1); + } + + // An inner range catches an off-by-one at either end. + var (inner, _) = SweepWithBoostSchedule( + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFloorReward, "2~4", 0, 10)); + Assert.Equal(expected, inner); + } + + [Theory] + // Sweep grants no first-clear reward. + [InlineData("ADVENTURE_BOSS_FIRST_CLEAR_REWARD", "*", 0L, 10L)] + // No floor in range. + [InlineData("ADVENTURE_BOSS_FLOOR_REWARD", "21~30", 0L, 10L)] + // Before and after the scheduled blocks. + [InlineData("ADVENTURE_BOSS_FLOOR_REWARD", "*", 2L, 10L)] + [InlineData("ADVENTURE_BOSS_FLOOR_REWARD", "*", 0L, 1L)] + public void Execute_IgnoresBoostThatDoesNotMatch( + string target, + string targetId, + long start, + long end) + { + var (baseRewards, baseScore) = SweepWithBoostSchedule(null); + var (rewards, score) = SweepWithBoostSchedule(BoostCsv(target, targetId, start, end)); + + Assert.Equal(baseScore, score); + Assert.Equal(baseRewards, rewards); + } + + [Fact] + public void Execute_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet() + { + var (neverPatched, neverPatchedScore) = SweepWithBoostSchedule(null); + var (emptySheet, emptySheetScore) = SweepWithBoostSchedule( + "id,target,target_id,op,value,start_block,end_block\n"); + + Assert.Contains(neverPatched, pair => pair.Value > 0); + Assert.Equal(neverPatchedScore, emptySheetScore); + Assert.Equal(neverPatched, emptySheet); + } + + private static string BoostCsv(string target, string targetId, long start, long end) => + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{target},{targetId},MUL,2,{start},{end}\n"; + + // Sums or subtracts per key, leaving out keys that end at zero. + private static Dictionary Combine( + Dictionary left, + Dictionary right, + int sign) => + left.Keys.Union(right.Keys) + .Select(key => (key, value: left.GetValueOrDefault(key) + (sign * right.GetValueOrDefault(key)))) + .Where(pair => pair.value != 0) + .ToDictionary(pair => pair.key, pair => pair.value); + + // Makes season 1 fight AdventureBossSheet id 2, whose floor rows are numbered from 31, so + // that a boost matched against the floor row id instead of the floor number misses. + private static IWorld UseBoostTestBoss(IWorld state) + { + var floorOneRow = TableSheets.AdventureBossFloorSheet.Values + .First(row => row.AdventureBossId == BoostTestAdventureBossId && row.Floor == 1); + Assert.NotEqual(1, floorOneRow.Id); + var season = state.GetSeasonInfo(1); + season.BossId = TableSheets.AdventureBossSheet[BoostTestAdventureBossId].BossId; + return state.SetSeasonInfo(season).SetLatestAdventureBossSeason(season); + } + + // Sweeps 20 cleared floors at block 1 with a fixed seed, with the BoostScheduleSheet + // given as CSV or, for null, a chain that was never patched with it. Returns what was + // granted, by item id and currency ticker, and the explorer's score. + private (Dictionary Rewards, int Score) SweepWithBoostSchedule( + string boostCsv) + { + var state = _initialState; + var gameConfigState = new GameConfigState(Sheets[nameof(GameConfigSheet)]); + state = state.SetLegacyState(gameConfigState.address, gameConfigState.Serialize()); + foreach (var (key, value) in Sheets) + { + state = state.SetLegacyState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + + state = state.SetLegacyState( + Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)), + boostCsv is null ? Null.Value : boostCsv.Serialize()); + + var validatorKey = new PrivateKey().PublicKey; + state = DelegationUtil.EnsureValidatorPromotionReady(state, validatorKey, 0L); + state = DelegationUtil.MakeGuild(state, WantedAddress, validatorKey.Address, 0L); + state = Stake(state, WantedAddress); + + var apPotionRow = TableSheets.MaterialItemSheet.OrderedList + .First(row => row.ItemSubType == ItemSubType.ApStone); + var inventory = state.GetInventoryV2(TesterAvatarAddress); + inventory.AddItem(ItemFactory.CreateMaterial(apPotionRow), 20); + state = state.SetInventory(TesterAvatarAddress, inventory); + + state = new Wanted + { + Season = 1, + AvatarAddress = WantedAvatarAddress, + Bounty = gameConfigState.AdventureBossMinBounty * NCG, + }.Execute( + new ActionContext + { + PreviousState = state, + Signer = WantedAddress, + BlockIndex = 0L, + RandomSeed = 1, + }); + state = UseBoostTestBoss(state); + Assert.Equal( + TableSheets.AdventureBossSheet[BoostTestAdventureBossId].BossId, + state.GetLatestAdventureBossSeason().BossId); + state = state.SetExplorer( + 1, + new Explorer(TesterAvatarAddress, TesterAvatarState.name) + { + MaxFloor = 20, + Floor = 20, + }); + var before = Granted(state); + + state = new SweepAdventureBoss + { + Season = 1, + AvatarAddress = TesterAvatarAddress, + Costumes = new List(), + Equipments = new List(), + RuneInfos = new List(), + }.Execute( + new ActionContext + { + PreviousState = state, + Signer = TesterAddress, + BlockIndex = 1L, + RandomSeed = 7, + }); + + return ( + Combine(Granted(state), before, -1), + state.GetExplorer(1, TesterAvatarAddress).Score); + } + + private Dictionary Granted(IWorld state) + { + var granted = new Dictionary(); + foreach (var item in state.GetInventoryV2(TesterAvatarAddress).Items + .Where(i => i.item.ItemSubType != ItemSubType.ApStone)) + { + var key = $"item:{item.item.Id}"; + granted[key] = granted.GetValueOrDefault(key) + item.count; + } + + var currencies = TableSheets.RuneSheet.Values + .Select(row => Currencies.GetRune(row.Ticker)) + .Append(Currencies.Crystal); + foreach (var currency in currencies) + { + var balance = state.GetBalance(TesterAvatarAddress, currency).MajorUnit; + if (balance != 0) + { + granted[$"fav:{currency.Ticker}"] = balance; + } + } + + return granted; + } + private IWorld Stake(IWorld world, Address agentAddress) { var action = new Stake(new BigInteger(500_000), TesterAvatarAddress); diff --git a/.Lib9c.Tests/Helper/AdventureBossHelperTest.cs b/.Lib9c.Tests/Helper/AdventureBossHelperTest.cs index 60531d82a1e..9c0b0917b93 100644 --- a/.Lib9c.Tests/Helper/AdventureBossHelperTest.cs +++ b/.Lib9c.Tests/Helper/AdventureBossHelperTest.cs @@ -9,6 +9,7 @@ namespace Lib9c.Tests.Helper using Nekoyume.Helper; using Nekoyume.Model.AdventureBoss; using Nekoyume.TableData; + using Nekoyume.TableData.AdventureBoss; using Xunit; public class AdventureBossHelperTest @@ -126,5 +127,43 @@ out var ncgReward Assert.Equal(expectedNcgReward * NCG, ncgReward); Assert.Equal(expectedNcgReward * NCG, claimableReward.NcgReward); } + + [Theory] + [InlineData("MUL", "2", 3, 6)] + [InlineData("MUL", "1.5", 3, 4)] + [InlineData("MUL", "1.5", 1, 1)] + [InlineData("MUL", "0.1", 3, 1)] + [InlineData("ADD", "5", 3, 8)] + [InlineData("ADD", "-10", 3, 1)] + [InlineData("MUL", "2", 0, 0)] + [InlineData("ADD", "5", 0, 0)] + public void ApplyRewardBoost(string op, string value, int amount, int expected) + { + var boost = RewardBoost(op, value); + var reward = new AdventureBossSheet.RewardAmountData("Material", 600302, amount); + + var actual = AdventureBossHelper.ApplyRewardBoost(reward, boost); + + Assert.Equal("Material", actual.ItemType); + Assert.Equal(600302, actual.ItemId); + Assert.Equal(expected, actual.Amount); + } + + [Fact] + public void ApplyRewardBoost_ReturnsTheSameRewardWithoutBoost() + { + var reward = new AdventureBossSheet.RewardAmountData("Rune", 10037, 3); + + Assert.Same(reward, AdventureBossHelper.ApplyRewardBoost(reward, null)); + } + + private static BoostScheduleSheet.Row RewardBoost(string op, string value) + { + var sheet = new BoostScheduleSheet(); + sheet.Set( + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.AdventureBossFloorReward},*,{op},{value},0,10\n"); + return sheet.FindActive(BoostScheduleSheet.Targets.AdventureBossFloorReward, 1, 0); + } } } diff --git a/Lib9c/Action/AdventureBoss/ExploreAdventureBoss.cs b/Lib9c/Action/AdventureBoss/ExploreAdventureBoss.cs index 163cdc066ec..a02e90707ec 100644 --- a/Lib9c/Action/AdventureBoss/ExploreAdventureBoss.cs +++ b/Lib9c/Action/AdventureBoss/ExploreAdventureBoss.cs @@ -260,6 +260,7 @@ public override IWorld Execute(IActionContext context) .Where(row => row.AdventureBossId == bossId).ToList(); var firstRewardSheet = states.GetSheet(); var pointSheet = states.GetSheet(); + states.TryGetPatchedSheet(out var boostScheduleSheet); AdventureBossSimulator simulator = null; var firstFloorId = 0; @@ -331,11 +332,16 @@ public override IWorld Execute(IActionContext context) exploreBoard.TotalPoint += point; var firstReward = firstRewardSheet[floorRow.Id]; + var firstClearBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.AdventureBossFirstClearReward, + fl, + context.BlockIndex); foreach (var reward in firstReward.Rewards) { - rewardList.Add(new AdventureBossSheet.RewardAmountData( - reward.ItemType, reward.ItemId, reward.Amount - )); + rewardList.Add(AdventureBossHelper.ApplyRewardBoost( + new AdventureBossSheet.RewardAmountData( + reward.ItemType, reward.ItemId, reward.Amount), + firstClearBoost)); } selector.Clear(); @@ -345,11 +351,16 @@ public override IWorld Execute(IActionContext context) } var selected = selector.Select(1).First(); - rewardList.Add(new AdventureBossSheet.RewardAmountData( - selected.ItemType, - selected.ItemId, - random.Next(selected.Min, selected.Max + 1)) - ); + var floorBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.AdventureBossFloorReward, + fl, + context.BlockIndex); + rewardList.Add(AdventureBossHelper.ApplyRewardBoost( + new AdventureBossSheet.RewardAmountData( + selected.ItemType, + selected.ItemId, + random.Next(selected.Min, selected.Max + 1)), + floorBoost)); // Add floorId for breakthrough if (fl < explorer.MaxFloor + 1) diff --git a/Lib9c/Action/AdventureBoss/SweepAdventureBoss.cs b/Lib9c/Action/AdventureBoss/SweepAdventureBoss.cs index 205dec38881..fb9adce0f06 100644 --- a/Lib9c/Action/AdventureBoss/SweepAdventureBoss.cs +++ b/Lib9c/Action/AdventureBoss/SweepAdventureBoss.cs @@ -227,6 +227,7 @@ public override IWorld Execute(IActionContext context) .First(row => row.BossId == latestSeason.BossId).Id; var floorPointSheet = states.GetSheet(); var floorSheet = states.GetSheet(); + states.TryGetPatchedSheet(out var boostScheduleSheet); for (var fl = 1; fl <= explorer.Floor; fl++) { var floorRow = floorSheet.Values.Where(row => row.AdventureBossId == bossId) @@ -241,11 +242,16 @@ public override IWorld Execute(IActionContext context) } var selected = selector.Select(1).First(); - rewardList.Add(new AdventureBossSheet.RewardAmountData( - selected.ItemType, - selected.ItemId, - random.Next(selected.Min, selected.Max + 1) - )); + var floorBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.AdventureBossFloorReward, + fl, + context.BlockIndex); + rewardList.Add(AdventureBossHelper.ApplyRewardBoost( + new AdventureBossSheet.RewardAmountData( + selected.ItemType, + selected.ItemId, + random.Next(selected.Min, selected.Max + 1)), + floorBoost)); } exploreBoard.TotalPoint += point; diff --git a/Lib9c/Helper/AdventureBossHelper.cs b/Lib9c/Helper/AdventureBossHelper.cs index 1bb622f2a8f..09de7d518dc 100644 --- a/Lib9c/Helper/AdventureBossHelper.cs +++ b/Lib9c/Helper/AdventureBossHelper.cs @@ -364,6 +364,41 @@ out FungibleAssetValue ncgReward return true; } + /// + /// Applies a row to one explore or sweep reward entry. + /// + /// The reward as the sheets and the random draws decided it. + /// + /// The row adjusting this floor's reward at the current block — see + /// and + /// — or + /// null for none. A caller replaying an action must pass what the action resolved. + /// + /// + /// itself when there is no row or nothing to adjust, otherwise a + /// copy whose is adjusted. + /// + /// + /// Only the granted amount changes: the entry was already drawn, so a boost never adds or + /// reorders random draws. A boost adjusts a reward but never creates one, so an entry with + /// no amount stays as it is; and it never takes a reward below 1, because granting 0 of + /// an item or a currency fails the action. + /// + public static AdventureBossSheet.RewardAmountData ApplyRewardBoost( + AdventureBossSheet.RewardAmountData reward, + BoostScheduleSheet.Row rewardBoost) + { + if (rewardBoost is null || reward.Amount <= 0) + { + return reward; + } + + return new AdventureBossSheet.RewardAmountData( + reward.ItemType, + reward.ItemId, + Math.Max(1, rewardBoost.Apply(reward.Amount))); + } + public static IWorld AddExploreRewards(IActionContext context, IWorld states, Address avatarAddress, Inventory inventory, IEnumerable rewardList) diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index 46333fc6ff4..6643da52ac5 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -98,6 +98,22 @@ public static class Targets /// public const string CostumeSummonGuarantee = "COSTUME_SUMMON_GUARANTEE"; + /// + /// Amount of the reward an adventure boss floor drops on every clear — the one entry + /// drawn from AdventureBossFloorSheet rewards per floor, by both + /// ExploreAdventureBoss and SweepAdventureBoss. target_id is the + /// floor number (AdventureBossFloorSheet floor, 1 for the first floor of + /// every boss), not the floor row id. + /// + public const string AdventureBossFloorReward = "ADVENTURE_BOSS_FLOOR_REWARD"; + + /// + /// Amount of each AdventureBossFloorFirstRewardSheet reward, granted by + /// ExploreAdventureBoss when a floor is cleared for the first time in a season. + /// target_id is the floor number, as for . + /// + public const string AdventureBossFirstClearReward = "ADVENTURE_BOSS_FIRST_CLEAR_REWARD"; + /// /// Every target above, for tooling that warns about rows no running code applies. /// @@ -106,6 +122,8 @@ public static class Targets EquipmentSummonGuarantee, RuneSummonGuarantee, CostumeSummonGuarantee, + AdventureBossFloorReward, + AdventureBossFirstClearReward, }; } From df659755e6bc3cc86b19b9e7a97ab59fc18f8a95 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Thu, 8 Oct 2026 14:34:53 +0900 Subject: [PATCH 28/41] =?UTF-8?q?feat:=20BoostScheduleSheet=20=EB=A1=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=85=8C=EC=9D=B4=EC=A7=80=20=EB=B3=B4=EC=83=81=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모험 스테이지(HackAndSlash, HackAndSlashSweep) 클리어 보상을 BoostScheduleSheet 행으로 블록 구간 동안 조정한다. 대상은 두 가지: - STAGE_ITEM_REWARD: 재료 아이템 보상. target_id = StageSheet 스테이지 id - STAGE_FAV_REWARD: 크리스탈·룬스톤 등 FAV 보상. target_id = StageSheet 스테이지 id 결정 사항 - 뽑기가 끝난 뒤 수량만 조정한다. 난수 호출 횟수·순서는 부스트가 있어도 그대로라 같은 전투가 같은 결과로 재생된다(경험치·클리어 여부 동일). - 클리어 1회 단위, 서로 다른 아이템 id 단위로 n → Apply(n). MUL 2 = 뽑힌 아이템이 정확히 2배, ADD 1 = 종류별 1개 더, 1 미만 배율은 감소. DropItemMax 는 뽑기 횟수만 제한하므로 넘을 수 있다. FAV 는 클리어 1회의 티커별 수량에 적용하고, 0 이 되면 민트하지 않는다. - 부스트는 뽑힌 보상만 조정하고 새로 만들지 않는다(뽑히지 않은/0 으로 뽑힌 FAV 는 그대로). 늘어난 재료도 일반 보상과 같아서 수집 퀘스트 진행에 반영된다. - 아이템은 클리어당 뽑힌 수의 10배(MaxItemRewardBoostFactor)로 상한. 값 오타의 과지급을 10배로 묶고, 클리어당 생성 객체 수를 DropItemMax 의 10배로 묶는다. - 소탕은 클리어마다 같은 규칙으로 적용해 HackAndSlash N 회와 지급 총량이 같다. 다만 최대 ~2,400 클리어라 늘어난 수량을 단위마다 객체로 만들면 AddItem(매번 정렬)이 배율에 비례해 블록 평가 비용을 키운다. 그래서 SplitItemRewardBoost 로 추가분을 아이템 id 별 개수로만 모아 id 당 AddItem(item, count) 한 번으로 넣는다. 부스트가 없으면 기존 UpdateInventory 경로 그대로. - 클라이언트 재생: HackAndSlashSweep.GetRewardItems(5인자) 와 StageSimulator.GetWaveRewards 는 추가분까지 객체로 펼친 목록을 돌려주며, id 별 개수가 실제 지급과 같다. - 이벤트 던전(EventDungeonBattle/Sweep)은 대상이 아니다. 스테이지 id 공간이 달라서 필요하면 별도 target 으로 추가한다. - 클라이언트가 같은 시드로 보상을 재생하므로 GetWaveRewards / GetFavWaveRewards / StageSimulator 생성자 / HackAndSlashSweep.GetRewardItems 에 선택 인자로 받는다. 시트가 패치된 적 없으면(TryGetPatchedSheet false) 기존과 바이트 단위로 동일. Co-Authored-By: Claude Opus 5.5 --- .Lib9c.Tests/Action/HackAndSlashSweepTest.cs | 350 ++++++++++++++++++ .Lib9c.Tests/Action/HackAndSlashTest.cs | 180 +++++++++ .../Action/StageRewardBoostFixture.cs | 149 ++++++++ .Lib9c.Tests/Model/StageRewardBoostTest.cs | 282 ++++++++++++++ Lib9c/Action/HackAndSlash.cs | 21 +- Lib9c/Action/HackAndSlashSweep.cs | 137 ++++++- Lib9c/Battle/StageSimulator.cs | 260 ++++++++++++- Lib9c/TableData/BoostScheduleSheet.cs | 37 ++ 8 files changed, 1401 insertions(+), 15 deletions(-) create mode 100644 .Lib9c.Tests/Action/StageRewardBoostFixture.cs create mode 100644 .Lib9c.Tests/Model/StageRewardBoostTest.cs diff --git a/.Lib9c.Tests/Action/HackAndSlashSweepTest.cs b/.Lib9c.Tests/Action/HackAndSlashSweepTest.cs index d31b14b77bc..033bc1c8c11 100644 --- a/.Lib9c.Tests/Action/HackAndSlashSweepTest.cs +++ b/.Lib9c.Tests/Action/HackAndSlashSweepTest.cs @@ -12,12 +12,14 @@ namespace Lib9c.Tests.Action using Libplanet.Types.Assets; using Nekoyume; using Nekoyume.Action; + using Nekoyume.Battle; using Nekoyume.Exceptions; using Nekoyume.Extensions; using Nekoyume.Helper; using Nekoyume.Model; using Nekoyume.Model.EnumType; using Nekoyume.Model.Item; + using Nekoyume.Model.Quest; using Nekoyume.Model.Rune; using Nekoyume.Model.State; using Nekoyume.Module; @@ -1178,5 +1180,353 @@ public void Execute_ArgumentOutOfRangeException(int ap, int apPotion) })); } } + + [Theory] + [InlineData(150, true)] // inside [100, 200) + [InlineData(250, false)] // after the schedule ends + public void Execute_AppliesScheduledStageRewardBoost(long blockIndex, bool scheduled) + { + const int stageId = 1; + var (state, prevMaterials) = PrepareStageRewardBoost(stageId); + + var plain = ExecuteStageRewardBoost( + StageRewardBoostFixture.WithoutBoostSchedule(state), + stageId, + blockIndex); + var patched = ExecuteStageRewardBoost( + StageRewardBoostFixture.WithBoostSchedule(state, StageRewardBoostFixture.BoostCsv(stageId)), + stageId, + blockIndex); + + Assert.Equal(plain.Exp, patched.Exp); + Assert.True(plain.Fav.MajorUnit >= 2000, "the sweep should cover several clears"); + if (!scheduled) + { + Assert.Equal(plain.Inventory, patched.Inventory); + Assert.Equal(plain.Fav, patched.Fav); + return; + } + + // Every clear of the sweep is doubled, exactly as HackAndSlash doubles each clear. + Assert.Equal(plain.Fav * 2, patched.Fav); + var gained = 0; + foreach (var id in plain.Materials.Keys.Union(patched.Materials.Keys)) + { + prevMaterials.TryGetValue(id, out var prev); + plain.Materials.TryGetValue(id, out var plainCount); + patched.Materials.TryGetValue(id, out var patchedCount); + Assert.Equal(2 * (plainCount - prev), patchedCount - prev); + gained += plainCount - prev; + } + + Assert.True(gained > 0, "the stage should reward items"); + } + + [Fact] + public void Execute_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet() + { + const int stageId = 1; + const long blockIndex = 150; + var (state, _) = PrepareStageRewardBoost(stageId); + + var unpatched = ExecuteStageRewardBoost( + StageRewardBoostFixture.WithoutBoostSchedule(state), + stageId, + blockIndex); + var empty = ExecuteStageRewardBoost( + StageRewardBoostFixture.WithBoostSchedule(state, StageRewardBoostFixture.EmptyCsv), + stageId, + blockIndex); + var unmatched = ExecuteStageRewardBoost( + StageRewardBoostFixture.WithBoostSchedule(state, StageRewardBoostFixture.UnmatchedCsv(stageId)), + stageId, + blockIndex); + + foreach (var other in new[] { empty, unmatched }) + { + Assert.Equal(unpatched.Inventory, other.Inventory); + Assert.Equal(unpatched.Fav, other.Fav); + Assert.Equal(unpatched.Exp, other.Exp); + } + } + + [Theory] + [InlineData("ADD", "1")] + [InlineData("MUL", "1.5")] + public void Execute_AppliesStageRewardBoostPerClear(string op, string value) + { + // Unlike MUL 2, these differ between applying per clear and applying once to the + // sweep's total, so they pin down that each clear is boosted on its own. + const int stageId = 1; + const long blockIndex = 150; + var (state, prevMaterials) = PrepareStageRewardBoost(stageId); + var csv = StageRewardBoostFixture.BoostCsv(stageId, op, value); + var boost = new BoostScheduleSheet(); + boost.Set(csv); + var itemBoost = boost[1]; + var favBoost = boost[2]; + + var plain = ExecuteStageRewardBoost( + StageRewardBoostFixture.WithoutBoostSchedule(state), + stageId, + blockIndex); + var patched = ExecuteStageRewardBoost( + StageRewardBoostFixture.WithBoostSchedule(state, csv), + stageId, + blockIndex); + + // Replay the sweep's draws (ActionContext.GetRandom is a fresh TestRandom(seed)) and + // boost every clear independently of the code under test. + var stageRow = state.GetSheet()[stageId]; + var materialItemSheet = state.GetSheet(); + var playCount = (int)(plain.Fav.MajorUnit / 1000); + var random = new TestRandom(0); + var maxCount = random.Next(stageRow.DropItemMin, stageRow.DropItemMax + 1); + var perClear = new Dictionary(); + var drawnTotal = new Dictionary(); + for (var i = 0; i < playCount; i++) + { + var selector = StageSimulatorV1.SetItemSelector(stageRow, random); + var drawn = Simulator.SetRewardV2(selector, maxCount, random, materialItemSheet); + foreach (var group in drawn.GroupBy(item => item.Id)) + { + perClear.TryGetValue(group.Key, out var boosted); + perClear[group.Key] = boosted + itemBoost.Apply(group.Count()); + drawnTotal.TryGetValue(group.Key, out var total); + drawnTotal[group.Key] = total + group.Count(); + } + } + + Assert.True(playCount > 1, "the sweep should cover several clears"); + Assert.Contains(perClear, pair => pair.Value != itemBoost.Apply(drawnTotal[pair.Key])); + + foreach (var id in perClear.Keys.Union(patched.Materials.Keys)) + { + prevMaterials.TryGetValue(id, out var prev); + patched.Materials.TryGetValue(id, out var patchedCount); + perClear.TryGetValue(id, out var expected); + Assert.Equal(expected, patchedCount - prev); + } + + var favCurrency = Currencies.GetCurrencyByTicker(StageRewardBoostFixture.FavTicker); + Assert.Equal(favCurrency * (favBoost.Apply(1000) * playCount), patched.Fav); + + // The client replays the sweep with GetRewardItems, which must add up to the grant. + var replayed = HackAndSlashSweep.GetRewardItems( + new TestRandom(0), + playCount, + stageRow, + materialItemSheet, + itemBoost); + Assert.Equal( + perClear.OrderBy(pair => pair.Key), + replayed.GroupBy(item => item.Id) + .Select(group => new KeyValuePair(group.Key, group.Count())) + .OrderBy(pair => pair.Key)); + } + + [Fact] + public void Execute_AddsBoostedExtrasLikeTheExpandedRewards() + { + // Stage 11 is rewritten to drop several ids per clear, a circle among them, each up to + // twice per draw, so that MUL 2 counts extras for several ids across the sweep. + const int stageId = 11; + const int circleId = 600402; + const int otherDropId = 306024; + const long blockIndex = 150; + var collectQuestCsv = _sheets[nameof(CollectQuestSheet)].TrimEnd('\n', '\r') + + $"\n299998,1000000,22,{circleId}\n299999,1000000,22,{otherDropId}\n"; + var state = StageRewardBoostFixture.WithBoostSchedule( + _initialState, + StageRewardBoostFixture.BoostCsv(stageId, "MUL", "2")) + .SetLegacyState( + Addresses.TableSheet.Derive(nameof(StageSheet)), + StageRewardBoostFixture.WithItemReward(_sheets[nameof(StageSheet)], stageId, circleId, 4, 6) + .Serialize()) + .SetLegacyState( + Addresses.TableSheet.Derive(nameof(CollectQuestSheet)), + collectQuestCsv.Serialize()); + var avatarState = AvatarState.Create( + _avatarAddress, + _agentAddress, + 0, + state.GetAvatarSheets(), + _rankingMapAddress); + avatarState.level = 3; + avatarState.worldInformation = new WorldInformation(0, state.GetSheet(), 25); + state = AvatarCpBooster.Apply( + state.SetAvatarState(_avatarAddress, avatarState), + _sheets, + _avatarAddress); + Assert.Contains(avatarState.questList.OfType(), q => q.ItemId == circleId); + + var actionPoint = (int)state.GetActionPoint(_avatarAddress); + var nextState = new HackAndSlashSweep + { + costumes = new List(), + equipments = new List(), + runeInfos = new List(), + avatarAddress = _avatarAddress, + actionPoint = actionPoint, + apStoneCount = 0, + worldId = 1, + stageId = stageId, + }.Execute(new ActionContext + { + PreviousState = state, + Signer = _agentAddress, + RandomSeed = 0, + BlockIndex = blockIndex, + }); + var actual = nextState.GetAvatarState(_avatarAddress); + + // Expected: the same sweep granted one object per unit, the way the client replays it, + // through the plain UpdateInventory, on a copy of the avatar. + var stageRow = state.GetSheet()[stageId]; + var materialItemSheet = state.GetSheet(); + var levelSheet = state.GetSheet(); + var boost = new BoostScheduleSheet(); + boost.Set(StageRewardBoostFixture.BoostCsv(stageId, "MUL", "2")); + var playCount = actionPoint / stageRow.CostAP; + var expanded = HackAndSlashSweep.GetRewardItems( + new TestRandom(0), + playCount, + stageRow, + materialItemSheet, + boost[1]); + var expected = state.GetAvatarState(_avatarAddress); + expected.UpdateMonsterMap(state.GetSheet(), stageId); + expected.UpdateInventory(expanded); + for (var i = 0; i < playCount; i++) + { + var (level, exp) = expected.GetLevelAndExp(levelSheet, stageId, 1); + expected.UpdateExp(level, exp); + } + + Assert.True(expanded.Select(item => item.Id).Distinct().Count() > 1, "several ids"); + Assert.Contains(expanded, item => item.Id == circleId); + Assert.Contains(expanded, item => item.Id == otherDropId); + var redraw = new TestRandom(0); + var maxCount = redraw.Next(stageRow.DropItemMin, stageRow.DropItemMax + 1); + var repeatedInAClear = false; + for (var i = 0; i < playCount; i++) + { + var selector = StageSimulatorV1.SetItemSelector(stageRow, redraw); + var drawn = Simulator.SetRewardV2(selector, maxCount, redraw, materialItemSheet); + repeatedInAClear |= drawn.GroupBy(item => item.Id).Any(group => group.Count() > 1); + } + + Assert.True(repeatedInAClear, "an id should be drawn several times in one clear"); + + // Per id and tradability rather than raw bytes: the action calls AddItem (and so + // List.Sort, which is unstable) fewer times, so equal entries may sit in another order. + Assert.Equal(Stacks(expected), Stacks(actual)); + Assert.All( + actual.inventory.Items.Where(i => i.item.Id == circleId), + i => Assert.IsType(i.item)); + Assert.Equal(expected.questList.Serialize(), actual.questList.Serialize()); + var circleQuest = (Dictionary)actual.questList.OfType() + .Single(q => q.ItemId == circleId) + .Serialize(); + Assert.Equal(expanded.Count(item => item.Id == circleId), (int)(Integer)circleQuest["current"]); + } + + [Fact] + public void GetRewardItems_IsUnchangedWithoutARow() + { + var stageRow = _tableSheets.StageSheet[1]; + var before = HackAndSlashSweep.GetRewardItems( + new TestRandom(11), + 10, + stageRow, + _tableSheets.MaterialItemSheet); + var after = HackAndSlashSweep.GetRewardItems( + new TestRandom(11), + 10, + stageRow, + _tableSheets.MaterialItemSheet, + null); + var boosted = HackAndSlashSweep.GetRewardItems( + new TestRandom(11), + 10, + stageRow, + _tableSheets.MaterialItemSheet, + StageRewardBoostFixture.Row(BoostScheduleSheet.Targets.StageItemReward, "MUL", 2)); + + Assert.Equal(before.Select(item => item.Id), after.Select(item => item.Id)); + Assert.Equal( + before.SelectMany(item => new[] { item.Id, item.Id }), + boosted.Select(item => item.Id)); + } + + private static List<(int Id, bool Tradable, int Count)> Stacks(AvatarState avatarState) => + avatarState.inventory.Items + .GroupBy(i => (i.item.Id, Tradable: i.item is ITradableItem)) + .Select(g => (g.Key.Id, g.Key.Tradable, g.Sum(i => i.count))) + .OrderBy(t => t.Id) + .ThenBy(t => t.Tradable) + .ToList(); + + private static Dictionary CountMaterials(AvatarState avatarState) => + avatarState.inventory.Items + .Where(i => i.item is Material) + .GroupBy(i => i.item.Id) + .ToDictionary(g => g.Key, g => g.Sum(i => i.count)); + + /// + /// An avatar that can sweep , which grants 1000 crystal per + /// clear. + /// + private (IWorld State, Dictionary Materials) PrepareStageRewardBoost(int stageId) + { + var state = _initialState.SetLegacyState( + Addresses.TableSheet.Derive(nameof(StageSheet)), + StageRewardBoostFixture.WithFavReward(_sheets[nameof(StageSheet)], stageId, 1000).Serialize()); + var avatarState = AvatarState.Create( + _avatarAddress, + _agentAddress, + 0, + state.GetAvatarSheets(), + _rankingMapAddress); + avatarState.level = 3; + avatarState.worldInformation = + new WorldInformation(0, state.GetSheet(), 25); + state = state.SetAvatarState(_avatarAddress, avatarState); + return (state, CountMaterials(avatarState)); + } + + private (IValue Inventory, Dictionary Materials, FungibleAssetValue Fav, long Exp) + ExecuteStageRewardBoost(IWorld state, int stageId, long blockIndex) + { + var action = new HackAndSlashSweep + { + costumes = new List(), + equipments = new List(), + runeInfos = new List(), + avatarAddress = _avatarAddress, + actionPoint = (int)state.GetActionPoint(_avatarAddress), + apStoneCount = 0, + worldId = 1, + stageId = stageId, + }; + + var nextState = action.Execute( + new ActionContext + { + PreviousState = state, + Signer = _agentAddress, + RandomSeed = 0, + BlockIndex = blockIndex, + }); + + var nextAvatarState = nextState.GetAvatarState(_avatarAddress); + var favCurrency = Currencies.GetCurrencyByTicker(StageRewardBoostFixture.FavTicker); + var recipient = Currencies.PickAddress(favCurrency, _agentAddress, _avatarAddress); + return ( + nextAvatarState.inventory.Serialize(), + CountMaterials(nextAvatarState), + nextState.GetBalance(recipient, favCurrency), + nextAvatarState.exp); + } } } diff --git a/.Lib9c.Tests/Action/HackAndSlashTest.cs b/.Lib9c.Tests/Action/HackAndSlashTest.cs index d047a6720a3..94629181728 100644 --- a/.Lib9c.Tests/Action/HackAndSlashTest.cs +++ b/.Lib9c.Tests/Action/HackAndSlashTest.cs @@ -35,6 +35,7 @@ public class HackAndSlashTest private const int ExtendedWorldId = 10; private const int ExtendedStageIdOffset = 450; private const int ExtendedEntryMaterialId = 900001; + private const int StageRewardBoostTotalPlayCount = 3; private readonly Dictionary _sheets; private readonly TableSheets _tableSheets; @@ -2163,6 +2164,75 @@ public void ExecuteDuplicatedException(int slotIndex, int runeId, int slotIndex2 })); } + [Theory] + [InlineData(150, true)] // inside [100, 200) + [InlineData(250, false)] // after the schedule ends + public void Execute_AppliesScheduledStageRewardBoost(long blockIndex, bool scheduled) + { + const int stageId = ExtendedStageIdOffset + 1; + var (state, prevMaterials) = PrepareStageRewardBoost(stageId); + + var plain = ExecuteStageRewardBoost( + StageRewardBoostFixture.WithoutBoostSchedule(state), + stageId, + blockIndex); + var patched = ExecuteStageRewardBoost( + StageRewardBoostFixture.WithBoostSchedule(state, StageRewardBoostFixture.BoostCsv(stageId)), + stageId, + blockIndex); + + // The boost draws nothing, so every battle plays out the same. + Assert.Equal(plain.Exp, patched.Exp); + Assert.Equal(StageRewardBoostTotalPlayCount * 1000, plain.Fav.MajorUnit); + if (!scheduled) + { + Assert.Equal(plain.Inventory, patched.Inventory); + Assert.Equal(plain.Fav, patched.Fav); + return; + } + + Assert.Equal(plain.Fav * 2, patched.Fav); + var gained = 0; + foreach (var id in plain.Materials.Keys.Union(patched.Materials.Keys)) + { + prevMaterials.TryGetValue(id, out var prev); + plain.Materials.TryGetValue(id, out var plainCount); + patched.Materials.TryGetValue(id, out var patchedCount); + Assert.Equal(2 * (plainCount - prev), patchedCount - prev); + gained += plainCount - prev; + } + + Assert.True(gained > 0, "the stage should reward items"); + } + + [Fact] + public void Execute_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet() + { + const int stageId = ExtendedStageIdOffset + 1; + var (state, _) = PrepareStageRewardBoost(stageId); + const long blockIndex = 150; + + var unpatched = ExecuteStageRewardBoost( + StageRewardBoostFixture.WithoutBoostSchedule(state), + stageId, + blockIndex); + var empty = ExecuteStageRewardBoost( + StageRewardBoostFixture.WithBoostSchedule(state, StageRewardBoostFixture.EmptyCsv), + stageId, + blockIndex); + var unmatched = ExecuteStageRewardBoost( + StageRewardBoostFixture.WithBoostSchedule(state, StageRewardBoostFixture.UnmatchedCsv(stageId)), + stageId, + blockIndex); + + foreach (var other in new[] { empty, unmatched }) + { + Assert.Equal(unpatched.Inventory, other.Inventory); + Assert.Equal(unpatched.Fav, other.Fav); + Assert.Equal(unpatched.Exp, other.Exp); + } + } + private static void SerializeException(Exception exec) where T : Exception { @@ -2175,5 +2245,115 @@ private static void SerializeException(Exception exec) Assert.Equal(exec.Message, deserialized.Message); } + + private static Dictionary CountMaterials(AvatarState avatarState) => + avatarState.inventory.Items + .Where(i => i.item is Material) + .GroupBy(i => i.item.Id) + .ToDictionary(g => g.Key, g => g.Sum(i => i.count)); + + /// + /// An avatar that wins every play of , which grants 1000 crystal + /// per clear, with every quest removed so that only stage rewards reach the inventory. + /// + private (IWorld State, Dictionary Materials) PrepareStageRewardBoost(int stageId) + { + const int normalFinalWorldId = 9; + const int normalFinalStageId = 450; + var state = _initialState.SetLegacyState( + Addresses.TableSheet.Derive(nameof(StageSheet)), + StageRewardBoostFixture.WithFavReward(_sheets[nameof(StageSheet)], stageId, 1000).Serialize()); + foreach (var name in new[] + { + nameof(WorldQuestSheet), nameof(CollectQuestSheet), nameof(CombinationQuestSheet), + nameof(TradeQuestSheet), nameof(MonsterQuestSheet), nameof(ItemEnhancementQuestSheet), + nameof(GeneralQuestSheet), nameof(ItemGradeQuestSheet), nameof(ItemTypeCollectQuestSheet), + nameof(GoldQuestSheet), nameof(CombinationEquipmentQuestSheet), + }) + { + var header = _sheets[name].Split('\n')[0].TrimEnd('\r'); + state = state.SetLegacyState(Addresses.TableSheet.Derive(name), header.Serialize()); + } + + var avatarState = state.GetAvatarState(_avatarAddress); + avatarState.level = 100; + avatarState.questList = new QuestList( + state.GetQuestSheet(), + state.GetSheet(), + state.GetSheet(), + state.GetSheet(), + state.GetSheet()); + var worldSheet = state.GetSheet(); + avatarState.worldInformation = new WorldInformation(0, worldSheet, normalFinalStageId); + avatarState.worldInformation.ClearStage( + normalFinalWorldId, + normalFinalStageId, + 1, + worldSheet, + state.GetSheet()); + + foreach (var equipment in Doomfist.GetAllParts(_tableSheets, avatarState.level)) + { + avatarState.inventory.AddItem(equipment, iLock: null); + } + + var stageRow = state.GetSheet()[stageId]; + if (stageRow.EntryCostItemId > 0) + { + var entryCostRow = state.GetSheet()[stageRow.EntryCostItemId]; + avatarState.inventory.AddItem( + ItemFactory.CreateTradableMaterial(entryCostRow), + stageRow.EntryCostItemCount * StageRewardBoostTotalPlayCount); + } + + state = state + .SetAvatarState(_avatarAddress, avatarState) + .SetLegacyState( + _avatarAddress.Derive("world_ids"), + List.Empty.Add(normalFinalWorldId.Serialize())); + state = AvatarCpBooster.Apply(state, _sheets, _avatarAddress); + return (state, CountMaterials(state.GetAvatarState(_avatarAddress))); + } + + private (IValue Inventory, Dictionary Materials, FungibleAssetValue Fav, long Exp) + ExecuteStageRewardBoost(IWorld state, int stageId, long blockIndex) + { + var avatarState = state.GetAvatarState(_avatarAddress); + var stageRow = state.GetSheet()[stageId]; + var action = new HackAndSlash + { + Costumes = new List(), + Equipments = avatarState.inventory.Equipments.Select(e => e.NonFungibleId).ToList(), + Foods = new List(), + RuneInfos = new List(), + WorldId = ExtendedWorldId, + StageId = stageId, + AvatarAddress = _avatarAddress, + TotalPlayCount = StageRewardBoostTotalPlayCount, + EntryCostItemId = stageRow.EntryCostItemId, + EntryCostItemCount = stageRow.EntryCostItemId > 0 + ? stageRow.EntryCostItemCount * StageRewardBoostTotalPlayCount + : 0, + }; + + var nextState = action.Execute( + new ActionContext + { + PreviousState = state, + Signer = _agentAddress, + RandomSeed = 0, + BlockIndex = blockIndex, + }); + + var nextAvatarState = nextState.GetAvatarState(_avatarAddress); + Assert.True(nextAvatarState.worldInformation.IsStageCleared(stageId)); + var favCurrency = Currencies.GetCurrencyByTicker(StageRewardBoostFixture.FavTicker); + var recipient = Currencies.PickAddress(favCurrency, _agentAddress, _avatarAddress); + return ( + nextAvatarState.inventory.Serialize(), + CountMaterials(nextAvatarState), + nextState.GetBalance(recipient, favCurrency), + nextAvatarState.exp); + } } } diff --git a/.Lib9c.Tests/Action/StageRewardBoostFixture.cs b/.Lib9c.Tests/Action/StageRewardBoostFixture.cs new file mode 100644 index 00000000000..c520ea87684 --- /dev/null +++ b/.Lib9c.Tests/Action/StageRewardBoostFixture.cs @@ -0,0 +1,149 @@ +namespace Lib9c.Tests.Action +{ + using System.Globalization; + using System.Linq; + using Bencodex.Types; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Nekoyume; + using Nekoyume.Model.State; + using Nekoyume.Module; + using Nekoyume.TableData; + + /// + /// Shared setup for tests of and + /// . + /// + public static class StageRewardBoostFixture + { + public const string FavTicker = "CRYSTAL"; + + public const long BoostStartBlock = 100; + + public const long BoostEndBlock = 200; + + /// + /// A schedule with no row at all. + /// + public const string EmptyCsv = "id,target,target_id,op,value,start_block,end_block\n"; + + public static readonly Address BoostScheduleSheetAddress = + Addresses.GetSheetAddress(); + + /// + /// A schedule doubling both the items and the fungible assets of + /// in [, ), plus rows that must + /// not apply: another target, and a stage range that excludes the stage. + /// + public static string BoostCsv(int stageId) => + "id,target,target_id,op,value,start_block,end_block,_memo\n" + + $"1,{BoostScheduleSheet.Targets.EquipmentSummonGuarantee},*,ADD,5,{BoostStartBlock},{BoostEndBlock},other target\n" + + $"2,{BoostScheduleSheet.Targets.StageItemReward},{stageId + 1}~{stageId + 10},MUL,3,{BoostStartBlock},{BoostEndBlock},other stages\n" + + $"3,{BoostScheduleSheet.Targets.StageItemReward},1~{stageId},MUL,2,{BoostStartBlock},{BoostEndBlock},\n" + + $"4,{BoostScheduleSheet.Targets.StageFavReward},{stageId},MUL,2,{BoostStartBlock},{BoostEndBlock},\n"; + + /// + /// A schedule whose rows are all active in [, + /// ) but none of which covers . + /// + /// + /// A schedule applying to both the items + /// and the fungible assets of in + /// [, ). + /// + public static string BoostCsv(int stageId, string op, string value) => + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.StageItemReward},{stageId},{op},{value},{BoostStartBlock},{BoostEndBlock}\n" + + $"2,{BoostScheduleSheet.Targets.StageFavReward},{stageId},{op},{value},{BoostStartBlock},{BoostEndBlock}\n"; + + public static string UnmatchedCsv(int stageId) => + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.EquipmentSummonGuarantee},*,ADD,5,{BoostStartBlock},{BoostEndBlock}\n" + + $"2,{BoostScheduleSheet.Targets.StageItemReward},{stageId + 1}~{stageId + 10},MUL,3,{BoostStartBlock},{BoostEndBlock}\n" + + $"3,{BoostScheduleSheet.Targets.StageFavReward},1~{stageId - 1},MUL,3,{BoostStartBlock},{BoostEndBlock}\n"; + + public static IWorld WithBoostSchedule(IWorld state, string csv) => + state.SetLegacyState(BoostScheduleSheetAddress, csv.Serialize()); + + public static IWorld WithoutBoostSchedule(IWorld state) => + state.SetLegacyState(BoostScheduleSheetAddress, Null.Value); + + /// + /// Rewrites of a StageSheet CSV so that every clear + /// draws exactly of . + /// + public static string WithFavReward(string stageCsv, int stageId, int amount) + { + var lines = stageCsv.Split('\n').ToList(); + var header = lines[0].TrimEnd('\r').Split(',').ToList(); + for (var i = 1; i < lines.Count; i++) + { + var cols = lines[i].TrimEnd('\r').Split(',').ToList(); + if (cols[0] != stageId.ToString()) + { + continue; + } + + while (cols.Count < header.Count) + { + cols.Add(string.Empty); + } + + for (var fav = 1; fav <= 5; fav++) + { + var used = fav == 1; + cols[header.IndexOf($"fungible_asset_reward_ticker_{fav}")] = used ? FavTicker : string.Empty; + cols[header.IndexOf($"fungible_asset_reward_ratio_{fav}")] = used ? "100" : string.Empty; + cols[header.IndexOf($"fungible_asset_reward_min_{fav}")] = used ? amount.ToString() : string.Empty; + cols[header.IndexOf($"fungible_asset_reward_max_{fav}")] = used ? amount.ToString() : string.Empty; + } + + cols[header.IndexOf("fav_drop_min")] = "1"; + cols[header.IndexOf("fav_drop_max")] = "1"; + lines[i] = string.Join(",", cols); + return string.Join('\n', lines); + } + + throw new System.ArgumentException($"stage {stageId} not found", nameof(stageId)); + } + + /// + /// Rewrites of a StageSheet CSV so that its first drop + /// is (1 to 2 per draw) and every clear draws + /// to items. + /// + public static string WithItemReward(string stageCsv, int stageId, int itemId, int dropMin, int dropMax) + { + var lines = stageCsv.Split('\n').ToList(); + var header = lines[0].TrimEnd('\r').Split(',').ToList(); + for (var i = 1; i < lines.Count; i++) + { + var cols = lines[i].TrimEnd('\r').Split(',').ToList(); + if (cols[0] != stageId.ToString()) + { + continue; + } + + cols[header.IndexOf("item1")] = itemId.ToString(); + cols[header.IndexOf("item1_ratio")] = "0.3"; + cols[header.IndexOf("item1_min")] = "1"; + cols[header.IndexOf("item1_max")] = "2"; + cols[header.IndexOf("min_drop")] = dropMin.ToString(); + cols[header.IndexOf("max_drop")] = dropMax.ToString(); + lines[i] = string.Join(",", cols); + return string.Join('\n', lines); + } + + throw new System.ArgumentException($"stage {stageId} not found", nameof(stageId)); + } + + public static BoostScheduleSheet.Row Row(string target, string op, decimal value) + { + var sheet = new BoostScheduleSheet(); + sheet.Set( + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{target},*,{op},{value.ToString(CultureInfo.InvariantCulture)},0,1\n"); + return sheet[1]; + } + } +} diff --git a/.Lib9c.Tests/Model/StageRewardBoostTest.cs b/.Lib9c.Tests/Model/StageRewardBoostTest.cs new file mode 100644 index 00000000000..95a6c6c369e --- /dev/null +++ b/.Lib9c.Tests/Model/StageRewardBoostTest.cs @@ -0,0 +1,282 @@ +namespace Lib9c.Tests.Model +{ + using System.Collections.Generic; + using System.Linq; + using Lib9c.Tests.Action; + using Libplanet.Action; + using Nekoyume.Battle; + using Nekoyume.Model.Item; + using Nekoyume.TableData; + using Xunit; + + public class StageRewardBoostTest + { + private const string Item = BoostScheduleSheet.Targets.StageItemReward; + private const string Fav = BoostScheduleSheet.Targets.StageFavReward; + + private readonly TableSheets _tableSheets; + + public StageRewardBoostTest() + { + _tableSheets = new TableSheets(TableSheetsImporter.ImportSheets()); + } + + [Fact] + public void ApplyItemRewardBoost_ReturnsTheSameListWithoutARow() + { + var rewards = Materials((ItemSubType.EquipmentMaterial, 2)); + + Assert.Same( + rewards, + StageSimulator.ApplyItemRewardBoost(rewards, null, _tableSheets.MaterialItemSheet)); + } + + [Theory] + [InlineData("MUL", "2", 3, 1, 6, 2)] // exactly twice each drawn item + [InlineData("MUL", "1.5", 3, 1, 4, 1)] // floor per distinct item: 1 × 1.5 stays 1 + [InlineData("ADD", "1", 3, 1, 4, 2)] // one more of each distinct item + [InlineData("MUL", "0.5", 3, 1, 1, 0)] // a multiplier below 1 takes items away + [InlineData("MUL", "100", 3, 1, 30, 10)] // capped at MaxItemRewardBoostFactor + public void ApplyItemRewardBoost_AdjustsTheCountOfEachDistinctItem( + string op, + string value, + int firstCount, + int secondCount, + int expectedFirst, + int expectedSecond) + { + var rows = MaterialRows(); + var rewards = Enumerable.Repeat(rows[0], firstCount) + .Concat(Enumerable.Repeat(rows[1], secondCount)) + .Select(row => (ItemBase)ItemFactory.CreateMaterial(row)) + .OrderBy(item => item.Id) + .ToList(); + var boost = StageRewardBoostFixture.Row(Item, op, decimal.Parse(value, System.Globalization.CultureInfo.InvariantCulture)); + + var boosted = StageSimulator.ApplyItemRewardBoost( + rewards, + boost, + _tableSheets.MaterialItemSheet); + + Assert.Equal(expectedFirst, boosted.Count(item => item.Id == rows[0].Id)); + Assert.Equal(expectedSecond, boosted.Count(item => item.Id == rows[1].Id)); + Assert.Equal(boosted.OrderBy(item => item.Id).Select(item => item.Id), boosted.Select(item => item.Id)); + Assert.Equal(expectedFirst + expectedSecond, boosted.Count); + } + + [Theory] + [InlineData("MUL", "2")] + [InlineData("ADD", "3")] + [InlineData("MUL", "0.5")] + public void SplitItemRewardBoost_AddsUpToApplyItemRewardBoost(string op, string value) + { + var rows = MaterialRows(); + var rewards = Enumerable.Repeat(rows[0], 3) + .Concat(Enumerable.Repeat(rows[1], 1)) + .Select(row => (ItemBase)ItemFactory.CreateMaterial(row)) + .ToList(); + var boost = StageRewardBoostFixture.Row(Item, op, decimal.Parse(value, System.Globalization.CultureInfo.InvariantCulture)); + var extraCounts = new Dictionary(); + + var kept = StageSimulator.SplitItemRewardBoost(rewards, boost, extraCounts); + var split = kept + .Concat(StageSimulator.CreateItemRewardBoostExtras(extraCounts, _tableSheets.MaterialItemSheet)) + .OrderBy(item => item.Id) + .Select(item => item.Id); + var applied = StageSimulator.ApplyItemRewardBoost(rewards, boost, _tableSheets.MaterialItemSheet) + .Select(item => item.Id); + + Assert.Equal(applied, split); + Assert.True(kept.Count <= rewards.Count, "added units are only counted"); + } + + [Fact] + public void SplitItemRewardBoost_LeavesEverythingAloneWithoutARow() + { + var rewards = Materials((ItemSubType.EquipmentMaterial, 2)); + var extraCounts = new Dictionary(); + + Assert.Same(rewards, StageSimulator.SplitItemRewardBoost(rewards, null, extraCounts)); + Assert.Empty(extraCounts); + } + + [Fact] + public void ApplyItemRewardBoost_AddsCirclesAsTradableMaterials() + { + var circleRow = _tableSheets.MaterialItemSheet.Values + .First(row => row.ItemSubType == ItemSubType.Circle); + var rewards = new List { ItemFactory.CreateTradableMaterial(circleRow) }; + + var boosted = StageSimulator.ApplyItemRewardBoost( + rewards, + StageRewardBoostFixture.Row(Item, "MUL", 3), + _tableSheets.MaterialItemSheet); + + Assert.Equal(3, boosted.Count); + Assert.All(boosted, item => Assert.IsType(item)); + } + + [Fact] + public void ApplyFavRewardBoost_ReturnsTheSameListWithoutARow() + { + var rewards = new List<(string ticker, int amount)> { ("CRYSTAL", 10) }; + + Assert.Same(rewards, StageSimulator.ApplyFavRewardBoost(rewards, null)); + } + + [Theory] + [InlineData("MUL", "2", 10, 20)] + [InlineData("MUL", "1.5", 3, 4)] // floor + [InlineData("ADD", "5", 10, 15)] + [InlineData("MUL", "0.01", 10, null)] // 0 is left out, so nothing mints a zero amount + public void ApplyFavRewardBoost_AdjustsEachDrawnAmount( + string op, + string value, + int amount, + int? expected) + { + var rewards = new List<(string ticker, int amount)> { ("CRYSTAL", amount), ("RUNE_GOLDENLEAF", 0) }; + var boost = StageRewardBoostFixture.Row(Fav, op, decimal.Parse(value, System.Globalization.CultureInfo.InvariantCulture)); + + var boosted = StageSimulator.ApplyFavRewardBoost(rewards, boost); + + var crystal = boosted.Where(r => r.ticker == "CRYSTAL").Select(r => (int?)r.amount).SingleOrDefault(); + Assert.Equal(expected, crystal); + + // A ticker drawn as 0 is not a reward the boost may create from nothing. + Assert.Contains(("RUNE_GOLDENLEAF", 0), boosted); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(5, 3)] + public void GetWaveRewards_DrawsTheSameAsWithoutARow(int seed, int playCount) + { + var stageRow = _tableSheets.StageSheet[1]; + var plainRandom = new CountingRandom(seed); + var boostedRandom = new CountingRandom(seed); + + var plain = StageSimulator.GetWaveRewards( + plainRandom, + stageRow, + _tableSheets.MaterialItemSheet, + playCount); + var boosted = StageSimulator.GetWaveRewards( + boostedRandom, + stageRow, + _tableSheets.MaterialItemSheet, + playCount, + StageRewardBoostFixture.Row(Item, "MUL", 2)); + + Assert.NotEmpty(plain); + Assert.Equal(plainRandom.Draws, boostedRandom.Draws); + Assert.Equal(plainRandom.Next(), boostedRandom.Next()); + Assert.Equal(2 * plain.Count, boosted.Count); + foreach (var id in plain.Select(item => item.Id).Distinct()) + { + Assert.Equal( + 2 * plain.Count(item => item.Id == id), + boosted.Count(item => item.Id == id)); + } + } + + [Fact] + public void GetWaveRewards_IsUnchangedWithoutARow() + { + var stageRow = _tableSheets.StageSheet[1]; + var before = StageSimulator.GetWaveRewards( + new TestRandom(7), + stageRow, + _tableSheets.MaterialItemSheet); + var after = StageSimulator.GetWaveRewards( + new TestRandom(7), + stageRow, + _tableSheets.MaterialItemSheet, + itemRewardBoost: null); + + Assert.Equal(before.Select(item => item.Id), after.Select(item => item.Id)); + } + + [Fact] + public void GetFavWaveRewards_DrawsTheSameAsWithoutARow() + { + var stageSheet = new StageSheet(); + stageSheet.Set(StageRewardBoostFixture.WithFavReward( + TableSheetsImporter.ImportSheets()[nameof(StageSheet)], + 1, + 1000)); + var stageRow = stageSheet[1]; + var plainRandom = new CountingRandom(3); + var boostedRandom = new CountingRandom(3); + + var plain = StageSimulator.GetFavWaveRewards(plainRandom, stageRow); + var boosted = StageSimulator.GetFavWaveRewards( + boostedRandom, + stageRow, + StageRewardBoostFixture.Row(Fav, "MUL", 2)); + + Assert.Equal(new[] { ("CRYSTAL", 1000) }, plain); + Assert.Equal(new[] { ("CRYSTAL", 2000) }, boosted); + Assert.Equal(plainRandom.Draws, boostedRandom.Draws); + Assert.Equal(plainRandom.Next(), boostedRandom.Next()); + } + + private List MaterialRows() => + _tableSheets.MaterialItemSheet.Values + .Where(row => row.ItemSubType == ItemSubType.EquipmentMaterial) + .OrderBy(row => row.Id) + .Take(2) + .ToList(); + + private List Materials((ItemSubType subType, int count) spec) => + Enumerable.Repeat( + _tableSheets.MaterialItemSheet.Values.First(row => row.ItemSubType == spec.subType), + spec.count) + .Select(row => (ItemBase)ItemFactory.CreateMaterial(row)) + .ToList(); + + private class CountingRandom : IRandom + { + private readonly TestRandom _random; + + public CountingRandom(int seed) + { + _random = new TestRandom(seed); + } + + public int Draws { get; private set; } + + public int Seed => _random.Seed; + + public int Next() + { + Draws++; + return _random.Next(); + } + + public int Next(int maxValue) + { + Draws++; + return _random.Next(maxValue); + } + + public int Next(int minValue, int maxValue) + { + Draws++; + return _random.Next(minValue, maxValue); + } + + public void NextBytes(byte[] buffer) + { + Draws++; + _random.NextBytes(buffer); + } + + public double NextDouble() + { + Draws++; + return _random.NextDouble(); + } + } + } +} diff --git a/Lib9c/Action/HackAndSlash.cs b/Lib9c/Action/HackAndSlash.cs index 8d1d4bd25aa..cd4e8805626 100644 --- a/Lib9c/Action/HackAndSlash.cs +++ b/Lib9c/Action/HackAndSlash.cs @@ -663,9 +663,25 @@ public IWorld Execute( var buffLimitSheet = sheets.GetSheet(); var buffLinkSheet = sheets.GetSheet(); + + // Never GetSheets: the sheet exists only once patched, and an unpatched chain must + // evaluate exactly as before. + states.TryGetPatchedSheet(out var boostScheduleSheet); + var itemRewardBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.StageItemReward, + StageId, + blockIndex); + var favRewardBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.StageFavReward, + StageId, + blockIndex); for (var i = 0; i < TotalPlayCount; i++) { - var rewards = StageSimulator.GetWaveRewards(random, stageRow, materialItemSheet); + var rewards = StageSimulator.GetWaveRewards( + random, + stageRow, + materialItemSheet, + itemRewardBoost: itemRewardBoost); sw.Restart(); // First simulating will use Foods and Random Skills. // Remainder simulating will not use Foods. @@ -690,7 +706,8 @@ public IWorld Execute( buffLimitSheet, buffLinkSheet, false, - gameConfigState.ShatterStrikeMaxDamage); + gameConfigState.ShatterStrikeMaxDamage, + favRewardBoost); sw.Stop(); Log.Verbose("{AddressesHex} {Source} HAS {Process} from #{BlockIndex}: {Elapsed}", addressesHex, source, "Initialize Simulator", blockIndex, sw.Elapsed.TotalMilliseconds); diff --git a/Lib9c/Action/HackAndSlashSweep.cs b/Lib9c/Action/HackAndSlashSweep.cs index b2f663d11fd..43a32a684aa 100644 --- a/Lib9c/Action/HackAndSlashSweep.cs +++ b/Lib9c/Action/HackAndSlashSweep.cs @@ -12,6 +12,7 @@ using Nekoyume.Exceptions; using Nekoyume.Extensions; using Nekoyume.Helper; +using Nekoyume.Model; using Nekoyume.Model.EnumType; using Nekoyume.Model.Item; using Nekoyume.Model.Stat; @@ -459,13 +460,35 @@ public override IWorld Execute(IActionContext context) avatarState.UpdateMonsterMap(stageWaveSheet, stageId); + // Never GetSheets: the sheet exists only once patched, and an unpatched chain must + // evaluate exactly as before. + states.TryGetPatchedSheet(out var boostScheduleSheet); + var itemRewardBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.StageItemReward, + stageId, + context.BlockIndex); + var favRewardBoost = boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.StageFavReward, + stageId, + context.BlockIndex); + var random = context.GetRandom(); + var extraRewardCounts = new Dictionary(); var rewardItems = GetRewardItems( random, playCount, stageRow, - materialItemSheet); - avatarState.UpdateInventory(rewardItems); + materialItemSheet, + itemRewardBoost, + extraRewardCounts); + if (extraRewardCounts.Count == 0) + { + avatarState.UpdateInventory(rewardItems); + } + else + { + UpdateInventory(avatarState, rewardItems, extraRewardCounts, materialItemSheet); + } var levelSheet = sheets.GetSheet(); for (var i = 0; i < playCount; i++) @@ -473,7 +496,7 @@ public override IWorld Execute(IActionContext context) var (newLevel, newExp) = avatarState.GetLevelAndExp(levelSheet, stageId, 1); avatarState.UpdateExp(newLevel, newExp); - foreach (var (ticker, amount) in StageSimulator.GetFavWaveRewards(random, stageRow)) + foreach (var (ticker, amount) in StageSimulator.GetFavWaveRewards(random, stageRow, favRewardBoost)) { var currency = Currencies.GetCurrencyByTicker(ticker); var recipient = Currencies.PickAddress(currency, context.Signer, avatarAddress); @@ -491,10 +514,75 @@ public override IWorld Execute(IActionContext context) return states; } + /// + /// Draws the item rewards of sweeps of + /// , one object per unit. + /// + /// The random number generator. + /// The number of clears swept. + /// The stage being swept. + /// The sheet the rewards are created from. + /// + /// The row adjusting each clear's items, or null + /// for none. Applied per clear exactly as HackAndSlash applies it; see + /// . It never changes the random draws. + /// A caller replaying a sweep must pass what the action resolved. + /// + /// + /// The items of every clear, one object per unit, ordered by item id. Their count per + /// item id is exactly what the action grants, although the action adds the units a + /// boost adds with one count per item id instead of creating these objects. + /// public static List GetRewardItems(IRandom random, int playCount, StageSheet.Row stageRow, - MaterialItemSheet materialItemSheet) + MaterialItemSheet materialItemSheet, + BoostScheduleSheet.Row itemRewardBoost = null) + { + var extraCounts = new Dictionary(); + var rewardItems = GetRewardItems( + random, + playCount, + stageRow, + materialItemSheet, + itemRewardBoost, + extraCounts); + if (extraCounts.Count == 0) + { + return rewardItems; + } + + rewardItems.AddRange( + StageSimulator.CreateItemRewardBoostExtras(extraCounts, materialItemSheet)); + return rewardItems.OrderBy(x => x.Id).ToList(); + } + + /// + /// Draws the item rewards of sweeps of + /// , counting the units a boost adds instead of creating them. + /// + /// The random number generator. + /// The number of clears swept. + /// The stage being swept. + /// The sheet the rewards are created from. + /// + /// The row adjusting each clear's items, or null + /// for none. See . + /// + /// + /// Accumulates, per item id, the units the boost adds on top of the returned items over + /// every clear. Stays empty without a row. + /// + /// + /// The drawn items that remain after the boost, one object per unit, ordered by item id. + /// Without a row, exactly the items the sweep drew. + /// + public static List GetRewardItems(IRandom random, + int playCount, + StageSheet.Row stageRow, + MaterialItemSheet materialItemSheet, + BoostScheduleSheet.Row itemRewardBoost, + IDictionary extraCounts) { var rewardItems = new List(); var maxCount = random.Next(stageRow.DropItemMin, stageRow.DropItemMax + 1); @@ -503,11 +591,50 @@ public static List GetRewardItems(IRandom random, var selector = StageSimulatorV1.SetItemSelector(stageRow, random); var rewards = Simulator.SetRewardV2(selector, maxCount, random, materialItemSheet); - rewardItems.AddRange(rewards); + rewardItems.AddRange( + StageSimulator.SplitItemRewardBoost(rewards, itemRewardBoost, extraCounts)); } rewardItems = rewardItems.OrderBy(x => x.Id).ToList(); return rewardItems; } + + /// + /// plus the units a boost adds, each + /// item id added once with its count so that the cost does not grow with the boost. + /// Grants the same counts, tradability and collect quest progress as adding the expanded + /// rewards one object per unit. + /// + /// The avatar receiving the rewards. + /// The drawn items that remain after the boost. + /// The units the boost adds, per item id. + /// The sheet the rewards are created from. + private static void UpdateInventory( + AvatarState avatarState, + List rewardItems, + IReadOnlyDictionary extraCounts, + MaterialItemSheet materialItemSheet) + { + var itemMap = new CollectionMap(); + foreach (var reward in rewardItems) + { + itemMap.Add(avatarState.inventory.AddItem(reward)); + } + + foreach (var (itemId, count) in extraCounts.OrderBy(pair => pair.Key)) + { + // Unreachable: every counted id came from an item created from this sheet. + if (!materialItemSheet.TryGetValue(itemId, out var materialRow)) + { + continue; + } + + itemMap.Add(avatarState.inventory.AddItem( + StageSimulator.CreateItemRewardBoostItem(materialRow), + count)); + } + + avatarState.questList.UpdateCollectQuest(itemMap); + } } } diff --git a/Lib9c/Battle/StageSimulator.cs b/Lib9c/Battle/StageSimulator.cs index c073ea39344..3f25ffb0d03 100644 --- a/Lib9c/Battle/StageSimulator.cs +++ b/Lib9c/Battle/StageSimulator.cs @@ -29,6 +29,19 @@ public class StageSimulator : Simulator, IStageSimulator private readonly List _favRewards; private readonly int _favDropMin; private readonly int _favDropMax; + private readonly BoostScheduleSheet.Row _favRewardBoost; + + /// + /// Upper bound of the boosted count of each distinct item in one clear, as a multiple of + /// how many times it was drawn in that clear: a row + /// grants at most this many times the drawn items per clear, whatever its value. It + /// bounds a mistyped value (e.g. MUL 200 for MUL 2.00) to a tenfold + /// over-reward, and the objects creates per clear to + /// this many times DropItemMax. It is not a per-transaction bound; + /// HackAndSlashSweep adds its extra units with one count per item id instead of one + /// object per unit, so its cost does not grow with the value. + /// + public const int MaxItemRewardBoostFactor = 10; /// /// Gets the collection map for items. @@ -92,6 +105,18 @@ public class StageSimulator : Simulator, IStageSimulator /// The experience points. /// The simulator sheets. /// The enemy skill sheet. + /// The costume stat sheet. + /// The item rewards already drawn for this clear. + /// The stat modifiers from activated collections. + /// The buff limit sheet. + /// The buff link sheet. + /// Whether to record the battle log. + /// Maximum damage for shatter strike. + /// + /// The row adjusting this stage's fungible asset rewards, + /// or null for none. See . A caller replaying a + /// battle must pass what the action resolved. + /// public StageSimulator(IRandom random, AvatarState avatarState, List foods, @@ -112,7 +137,8 @@ public StageSimulator(IRandom random, BuffLimitSheet buffLimitSheet, BuffLinkSheet buffLinkSheet, bool logEvent = true, - long shatterStrikeMaxDamage = 400_000 + long shatterStrikeMaxDamage = 400_000, + BoostScheduleSheet.Row favRewardBoost = null ) : base( random, @@ -150,6 +176,7 @@ public StageSimulator(IRandom random, _favRewards = stageRow.FavRewards; _favDropMin = stageRow.FavDropMin; _favDropMax = stageRow.FavDropMax; + _favRewardBoost = favRewardBoost; WorldId = worldId; StageId = stageId; IsCleared = isCleared; @@ -161,18 +188,217 @@ public StageSimulator(IRandom random, SetWave(stageRow, stageWaveRow); } + /// + /// Draws the fungible asset rewards of one clear of . + /// + /// The random number generator. + /// The stage being cleared. + /// + /// The row adjusting the amounts, or null for none. + /// See . It never changes the random draws. + /// + /// The amount drawn for each ticker. public static List<(string ticker, int amount)> GetFavWaveRewards( IRandom random, - StageSheet.Row stageRow) + StageSheet.Row stageRow, + BoostScheduleSheet.Row favRewardBoost = null) + { + return GetFavWaveRewards( + random, + stageRow.FavRewards, + stageRow.FavDropMin, + stageRow.FavDropMax, + favRewardBoost); + } + + /// + /// Applies a row to the fungible asset rewards of one + /// clear. + /// + /// The amount drawn for each ticker. + /// The row to apply, or null for none. + /// + /// itself when is + /// null; otherwise a new list with each positive amount replaced by + /// of it, in the same order. A ticker whose + /// adjusted amount is 0 is left out so that nothing mints a zero amount. + /// + /// + /// Draws nothing, so the random sequence is the same as without a row. A boost adjusts + /// what was drawn and never creates a reward: a ticker that was not drawn, or drawn as 0, + /// stays as it is. + /// + public static List<(string ticker, int amount)> ApplyFavRewardBoost( + List<(string ticker, int amount)> rewards, + BoostScheduleSheet.Row favRewardBoost) + { + if (favRewardBoost is null) + { + return rewards; + } + + var result = new List<(string ticker, int amount)>(rewards.Count); + foreach (var (ticker, amount) in rewards) + { + if (amount <= 0) + { + result.Add((ticker, amount)); + continue; + } + + var boosted = favRewardBoost.Apply(amount); + if (boosted > 0) + { + result.Add((ticker, boosted)); + } + } + + return result; + } + + /// + /// Applies a row to the item rewards of one clear, one + /// object per unit. + /// + /// The items drawn in one clear, one object per unit. + /// The row to apply, or null for none. + /// The sheet the rewards were created from. + /// + /// itself when is + /// null; otherwise a new list, ordered by item id, in which each distinct item + /// drawn n times appears (n) + /// times, at most × n. + /// + /// + /// Draws nothing, so the random sequence is the same as without a row. MUL 2 + /// grants exactly twice each drawn item; ADD 1 one more of each distinct item; a + /// multiplier below 1 takes items away. The count can exceed the stage's + /// DropItemMax, which only limits the draws. Added items are created the way + /// creates them, so a circle is tradable as usual. + /// Equivalent to followed by + /// . + /// + public static List ApplyItemRewardBoost( + List rewards, + BoostScheduleSheet.Row itemRewardBoost, + MaterialItemSheet materialItemSheet) + { + if (itemRewardBoost is null) + { + return rewards; + } + + var extraCounts = new Dictionary(); + var kept = SplitItemRewardBoost(rewards, itemRewardBoost, extraCounts); + if (extraCounts.Count == 0) + { + return kept; + } + + var extras = CreateItemRewardBoostExtras(extraCounts, materialItemSheet); + var result = new List(kept.Count + extras.Count); + result.AddRange(kept); + result.AddRange(extras); + return result.OrderBy(item => item.Id).ToList(); + } + + /// + /// Applies a row to the item rewards of one clear + /// without creating an object per added unit: units taken away are dropped from the + /// returned list, and units added are only counted into + /// . + /// + /// The items drawn in one clear, one object per unit. + /// The row to apply, or null for none. + /// + /// Accumulates, per item id, how many units the row adds on top of the returned list. + /// Left untouched when nothing is added, so one dictionary can collect several clears. + /// + /// + /// itself when is + /// null; otherwise the drawn items that remain, ordered by item id. Together with + /// they add up to exactly what + /// grants for the clear. + /// + public static List SplitItemRewardBoost( + List rewards, + BoostScheduleSheet.Row itemRewardBoost, + IDictionary extraCounts) + { + if (itemRewardBoost is null) + { + return rewards; + } + + var kept = new List(rewards.Count); + foreach (var group in rewards.GroupBy(item => item.Id).OrderBy(group => group.Key)) + { + var drawn = group.ToList(); + var count = drawn.Count; + var boosted = (int)Math.Min( + itemRewardBoost.Apply(count), + (long)count * MaxItemRewardBoostFactor); + if (boosted <= count) + { + kept.AddRange(drawn.Take(boosted)); + continue; + } + + kept.AddRange(drawn); + extraCounts.TryGetValue(group.Key, out var extra); + extraCounts[group.Key] = checked(extra + boosted - count); + } + + return kept; + } + + /// + /// Creates the items counted, one object per unit, + /// for a caller that needs objects (a battle's drop log, a result view). + /// + /// Units to create per item id. + /// The sheet the rewards were created from. + /// The items, ordered by item id. + public static List CreateItemRewardBoostExtras( + IReadOnlyDictionary extraCounts, + MaterialItemSheet materialItemSheet) { - return GetFavWaveRewards(random, stageRow.FavRewards, stageRow.FavDropMin, stageRow.FavDropMax); + var result = new List(); + foreach (var (itemId, count) in extraCounts.OrderBy(pair => pair.Key)) + { + // Unreachable: every counted id came from an item created from this sheet. + if (!materialItemSheet.TryGetValue(itemId, out var materialRow)) + { + continue; + } + + for (var i = 0; i < count; i++) + { + result.Add(CreateItemRewardBoostItem(materialRow)); + } + } + + return result; } + /// + /// Creates one unit of a boosted item reward the way + /// creates a drawn one, so a circle is tradable as usual. A fungible item, so one object + /// may stand for any number of units when added with a count. + /// + /// The item's row. + /// The item. + public static ItemBase CreateItemRewardBoostItem(MaterialItemSheet.Row materialRow) => + materialRow.ItemSubType is ItemSubType.Circle + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateMaterial(materialRow); + private static List<(string ticker, int amount)> GetFavWaveRewards( IRandom random, List favRewards, int favDropMin, - int favDropMax) + int favDropMax, + BoostScheduleSheet.Row favRewardBoost) { if (favRewards.Count == 0) return new List<(string, int)>(); @@ -196,14 +422,31 @@ public StageSimulator(IRandom random, result[selected.Ticker] = amount; } - return result.Select(kv => (kv.Key, kv.Value)).ToList(); + return ApplyFavRewardBoost( + result.Select(kv => (kv.Key, kv.Value)).ToList(), + favRewardBoost); } + /// + /// Draws the item rewards of clears of + /// . + /// + /// The random number generator. + /// The stage being cleared. + /// The sheet the rewards are created from. + /// The number of clears. + /// + /// The row adjusting each clear's items, or null + /// for none. See . It never changes the random draws. + /// A caller replaying a battle must pass what the action resolved. + /// + /// The items, one object per unit, each clear's ordered by item id. public static List GetWaveRewards( IRandom random, StageSheet.Row stageRow, MaterialItemSheet materialItemSheet, - int playCount = 1) + int playCount = 1, + BoostScheduleSheet.Row itemRewardBoost = null) { var maxCountForItemDrop = random.Next( stageRow.DropItemMin, @@ -219,7 +462,8 @@ public static List GetWaveRewards( materialItemSheet ); - waveRewards.AddRange(rewards); + waveRewards.AddRange( + ApplyItemRewardBoost(rewards, itemRewardBoost, materialItemSheet)); } return waveRewards; @@ -329,7 +573,7 @@ public Player Simulate() case 2: { ItemMap = Player.GetRewards(_waveRewards); - foreach (var (ticker, amount) in GetFavWaveRewards(Random, _favRewards, _favDropMin, _favDropMax)) + foreach (var (ticker, amount) in GetFavWaveRewards(Random, _favRewards, _favDropMin, _favDropMax, _favRewardBoost)) { FungibleAssetRewards[ticker] = amount; } diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index 46333fc6ff4..5b730131984 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -98,6 +98,41 @@ public static class Targets /// public const string CostumeSummonGuarantee = "COSTUME_SUMMON_GUARANTEE"; + /// + /// Material item rewards of an adventure stage clear (HackAndSlash, + /// HackAndSlashSweep). target_id is the StageSheet stage id, so + /// 1~300 covers stages 1 through 300. Event dungeon stages are not covered. + /// + /// Applied per clear to the count of each distinct item after the items are drawn, so + /// the random draws are the same as without the row: MUL 2 grants exactly + /// twice each drawn item and ADD 1 one more of each. See + /// StageSimulator.ApplyItemRewardBoost. + /// + /// + /// Boosted items are ordinary rewards everywhere else too: they count toward + /// collect quest progress, and a circle stays tradable. + /// + /// + /// Rounding is per item id per clear, so a MUL between 1 and 2 has no effect on + /// an item drawn once in a clear — which is how most stages drop. Prefer ADD or + /// an integer MUL for items; amounts are large, so a + /// fractional MUL scales them as expected. + /// + /// + public const string StageItemReward = "STAGE_ITEM_REWARD"; + + /// + /// Fungible asset rewards (e.g. crystal, rune stones) of an adventure stage clear + /// (HackAndSlash, HackAndSlashSweep). target_id is the + /// StageSheet stage id. Event dungeon stages are not covered. + /// + /// Applied per clear to the amount of each drawn ticker after it is drawn, so the + /// random draws are the same as without the row. See + /// StageSimulator.ApplyFavRewardBoost. + /// + /// + public const string StageFavReward = "STAGE_FAV_REWARD"; + /// /// Every target above, for tooling that warns about rows no running code applies. /// @@ -106,6 +141,8 @@ public static class Targets EquipmentSummonGuarantee, RuneSummonGuarantee, CostumeSummonGuarantee, + StageItemReward, + StageFavReward, }; } From 31bd3917ed1b11e95c596c6522432ba0fcce834b Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Mon, 12 Oct 2026 10:31:00 +0900 Subject: [PATCH 29/41] =?UTF-8?q?fix:=20=EB=A3=AC=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?=ED=95=A0=EC=9D=B8=EC=9D=84=20=EC=86=8C=ED=99=98=20=EB=A3=AC?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=95=9C=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기획(재호) 확인: 룬 강화 비용 할인은 룬 소환으로 얻을 수 있는 룬에만 적용한다. - target 이름을 SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST / SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST 로 바꿔 범위가 이름에 드러나게 했다 (PR 미머지라 변경 가능). target_id 는 여전히 도달 레벨. - "소환 룬" = RuneSummonSheet 어느 그룹이든 recipe id 로 등장하는 룬(비율 무관). id 목록을 박지 않고 실행 시점의 시트에서 유도하므로 소환 풀 변경을 따라간다. - RuneHelper.ResolveEnhancementCostBoost 가 판정한다. 활성 행이 하나라도 있을 때만 RuneSummonSheet 를 읽으므로(지연 로더) 부스트 없는 경로는 상태 읽기까지 이전과 동일하다. 시트가 없거나 못 읽으면 소환 룬 없음 → 할인 없음, throw 없음 (TryGetSheet). - TryEnhancement / ApplyEnhancementCostBoost 시그니처는 그대로이고, 여기에 넘길 시트는 Resolve 결과여야 한다고 문서화했다. Co-Authored-By: Claude Opus 5.5 --- .Lib9c.Tests/Action/RuneEnhancementTest.cs | 35 ++++++--- .Lib9c.Tests/Action/RuneHelperTest.cs | 90 +++++++++++++++++++--- Lib9c/Action/RuneEnhancement.cs | 10 ++- Lib9c/Helper/RuneHelper.cs | 87 +++++++++++++++++++-- Lib9c/TableData/BoostScheduleSheet.cs | 30 +++++--- 5 files changed, 211 insertions(+), 41 deletions(-) diff --git a/.Lib9c.Tests/Action/RuneEnhancementTest.cs b/.Lib9c.Tests/Action/RuneEnhancementTest.cs index 936207a7ec7..4d9e42d9cf0 100644 --- a/.Lib9c.Tests/Action/RuneEnhancementTest.cs +++ b/.Lib9c.Tests/Action/RuneEnhancementTest.cs @@ -14,16 +14,17 @@ namespace Lib9c.Tests.Action using Nekoyume.Module; using Nekoyume.TableData; using Nekoyume.TableData.Rune; + using Nekoyume.TableData.Summon; using Xunit; public class RuneEnhancementTest { private const string BoostCsv = "id,target,target_id,op,value,start_block,end_block\n" + - "1,RUNE_ENHANCEMENT_CRYSTAL_COST,1~50,MUL,0.60,100,200\n" + - "2,RUNE_ENHANCEMENT_CRYSTAL_COST,51~100,MUL,0.65,100,200\n" + - "3,RUNE_ENHANCEMENT_RUNE_STONE_COST,1~50,MUL,0.70,100,200\n" + - "4,RUNE_ENHANCEMENT_RUNE_STONE_COST,51~100,MUL,0.80,100,200\n"; + "1,SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST,1~50,MUL,0.60,100,200\n" + + "2,SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST,51~100,MUL,0.65,100,200\n" + + "3,SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST,1~50,MUL,0.70,100,200\n" + + "4,SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST,51~100,MUL,0.80,100,200\n"; private readonly Currency _goldCurrency; @@ -272,19 +273,25 @@ int seed } [Theory] - // Rune 10001 from level 48, four sure tries reaching 49, 50, 51 and 52: - // base 3000 stones (240 + 2400 + 180 + 180), 70000 crystal (20000 * 2 + 15000 * 2), 50 NCG. - [InlineData(150, true, 2136, 43500)] // stones * 0.7 / 0.8, crystal * 0.6 / 0.65 - [InlineData(99, true, 3000, 70000)] // before start_block - [InlineData(200, true, 3000, 70000)] // end_block is exclusive - [InlineData(150, false, 3000, 70000)] // never patched + // From level 48, four sure tries reaching 49, 50, 51 and 52. + // Rune 10001 is summonable: base 3000 stones (240 + 2400 + 180 + 180) and + // 70000 crystal (20000 * 2 + 15000 * 2). + [InlineData(10001, 150, true, true, 2136, 43500)] // stones * 0.7 / 0.8, crystal * 0.6 / 0.65 + [InlineData(10001, 99, true, true, 3000, 70000)] // before start_block + [InlineData(10001, 200, true, true, 3000, 70000)] // end_block is exclusive + [InlineData(10001, 150, false, true, 3000, 70000)] // never patched + [InlineData(10001, 150, true, false, 3000, 70000)] // RuneSummonSheet absent + // Rune 10036 is not in any RuneSummonSheet group: base 240 stones (30 + 30 + 90 + 90) and + // 240000 crystal (30000 + 150000 + 30000 + 30000), same levels, same schedule. + [InlineData(10036, 150, true, true, 240, 240000)] public void Execute_AppliesScheduledCostBoost( + int runeId, long blockIndex, bool patched, + bool hasRuneSummonSheet, int expectedRuneCost, int expectedCrystalCost) { - const int runeId = 10001; const int initialNcg = 10_000; const int initialCrystal = 1_000_000; const int initialRune = 10_000; @@ -315,6 +322,12 @@ public void Execute_AppliesScheduledCostBoost( state = patched ? state.SetLegacyState(boostAddress, BoostCsv.Serialize()) : state.SetLegacyState(boostAddress, Bencodex.Types.Null.Value); + if (!hasRuneSummonSheet) + { + state = state.SetLegacyState( + Addresses.TableSheet.Derive(nameof(RuneSummonSheet)), + Bencodex.Types.Null.Value); + } var allRuneState = new AllRuneState(runeId); allRuneState.GetRuneState(runeId).LevelUp(48); diff --git a/.Lib9c.Tests/Action/RuneHelperTest.cs b/.Lib9c.Tests/Action/RuneHelperTest.cs index 4635e5b9d63..34213c536eb 100644 --- a/.Lib9c.Tests/Action/RuneHelperTest.cs +++ b/.Lib9c.Tests/Action/RuneHelperTest.cs @@ -5,6 +5,7 @@ namespace Lib9c.Tests.Action using Nekoyume.Helper; using Nekoyume.Model.State; using Nekoyume.TableData; + using Nekoyume.TableData.Summon; using Xunit; public class RuneHelperTest @@ -56,7 +57,7 @@ public void ApplyEnhancementCostBoostQuantity( int baseCost, int expected) { - var boost = CreateBoostSheet($"1,RUNE_ENHANCEMENT_CRYSTAL_COST,*,{op},{value},0,10")[1]; + var boost = CreateBoostSheet($"1,SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST,*,{op},{value},0,10")[1]; Assert.Equal(expected, RuneHelper.ApplyEnhancementCostBoostQuantity(boost, baseCost)); } @@ -109,7 +110,7 @@ public void ApplyEnhancementCostBoost_IgnoresOtherTargets() { var sheet = CreateBoostSheet( "1,RUNE_SUMMON_GUARANTEE,*,MUL,0.5,0,10", - "2,RUNE_ENHANCEMENT_NCG_COST,*,MUL,0.5,0,10"); + "2,SUMMON_RUNE_ENHANCEMENT_NCG_COST,*,MUL,0.5,0,10"); var cost = new RuneCostSheet.RuneCostData(1, 1, 240, 20000, 50, 7000); Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 1, sheet, 5)); @@ -118,7 +119,7 @@ public void ApplyEnhancementCostBoost_IgnoresOtherTargets() [Fact] public void ApplyEnhancementCostBoost_AdjustsOnlyTheScheduledCurrency() { - var sheet = CreateBoostSheet("1,RUNE_ENHANCEMENT_RUNE_STONE_COST,1~10,MUL,0.5,0,10"); + var sheet = CreateBoostSheet("1,SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST,1~10,MUL,0.5,0,10"); var cost = new RuneCostSheet.RuneCostData(1, 1, 240, 20000, 50, 7000); var boosted = RuneHelper.ApplyEnhancementCostBoost(cost, 1, sheet, 5); @@ -210,19 +211,86 @@ public void TryEnhancement_WithoutBoost_IsUnchanged() Assert.Equal(expectedRandom.Next(), emptyRandom.Next()); } + [Theory] + [InlineData(10001, 150, true)] // summonable, inside the schedule + [InlineData(10036, 150, false)] // in no RuneSummonSheet group + [InlineData(10001, 250, false)] // after the schedule + public void ResolveEnhancementCostBoost_OnlyForSummonableRunes( + int runeId, + long blockIndex, + bool expected) + { + var sheet = TieredSheet(); + + var resolved = RuneHelper.ResolveEnhancementCostBoost( + sheet, + runeId, + blockIndex, + () => _tableSheets.RuneSummonSheet); + + Assert.Equal(expected ? sheet : null, resolved); + } + + [Fact] + public void ResolveEnhancementCostBoost_WithoutRuneSummonSheet_IsNone() + { + Assert.Null(RuneHelper.ResolveEnhancementCostBoost(TieredSheet(), 10001, 150, () => null)); + Assert.Null(RuneHelper.ResolveEnhancementCostBoost( + TieredSheet(), + 10001, + 150, + () => new RuneSummonSheet())); + } + + [Fact] + public void ResolveEnhancementCostBoost_ReadsRuneSummonSheetOnlyWhenARowIsActive() + { + var loads = 0; + RuneSummonSheet Load() + { + loads++; + return _tableSheets.RuneSummonSheet; + } + + var otherTargets = CreateBoostSheet("1,RUNE_SUMMON_GUARANTEE,*,ADD,1,0,1000"); + Assert.Null(RuneHelper.ResolveEnhancementCostBoost(null, 10001, 150, Load)); + Assert.Null(RuneHelper.ResolveEnhancementCostBoost(new BoostScheduleSheet(), 10001, 150, Load)); + Assert.Null(RuneHelper.ResolveEnhancementCostBoost(otherTargets, 10001, 150, Load)); + Assert.Null(RuneHelper.ResolveEnhancementCostBoost(TieredSheet(), 10001, 99, Load)); + Assert.Equal(0, loads); + + Assert.NotNull(RuneHelper.ResolveEnhancementCostBoost(TieredSheet(), 10001, 150, Load)); + Assert.Equal(1, loads); + } + + [Fact] + public void TryEnhancement_NonSummonableRune_IsUnchanged() + { + var costRow = _tableSheets.RuneCostSheet[10036]; + var resolved = RuneHelper.ResolveEnhancementCostBoost( + TieredSheet(), + 10036, + 150, + () => _tableSheets.RuneSummonSheet); + RuneHelper.TryEnhancement(48, costRow, new TestRandom(3), 4, out var expected); + RuneHelper.TryEnhancement(48, costRow, new TestRandom(3), 4, out var actual, resolved, 150); + + Assert.Equal(expected.ToString(), actual.ToString()); + } + /// /// The rotation event E3 curve, scheduled for blocks [100, 200). /// private static BoostScheduleSheet TieredSheet() => CreateBoostSheet( - "1,RUNE_ENHANCEMENT_CRYSTAL_COST,1~50,MUL,0.60,100,200", - "2,RUNE_ENHANCEMENT_CRYSTAL_COST,51~100,MUL,0.65,100,200", - "3,RUNE_ENHANCEMENT_CRYSTAL_COST,101~200,MUL,0.75,100,200", - "4,RUNE_ENHANCEMENT_CRYSTAL_COST,201~300,MUL,0.80,100,200", - "5,RUNE_ENHANCEMENT_RUNE_STONE_COST,1~50,MUL,0.70,100,200", - "6,RUNE_ENHANCEMENT_RUNE_STONE_COST,51~100,MUL,0.80,100,200", - "7,RUNE_ENHANCEMENT_RUNE_STONE_COST,101~200,MUL,0.90,100,200", - "8,RUNE_ENHANCEMENT_RUNE_STONE_COST,201~300,MUL,1.00,100,200"); + "1,SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST,1~50,MUL,0.60,100,200", + "2,SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST,51~100,MUL,0.65,100,200", + "3,SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST,101~200,MUL,0.75,100,200", + "4,SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST,201~300,MUL,0.80,100,200", + "5,SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST,1~50,MUL,0.70,100,200", + "6,SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST,51~100,MUL,0.80,100,200", + "7,SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST,101~200,MUL,0.90,100,200", + "8,SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST,201~300,MUL,1.00,100,200"); private static BoostScheduleSheet CreateBoostSheet(params string[] rows) { diff --git a/Lib9c/Action/RuneEnhancement.cs b/Lib9c/Action/RuneEnhancement.cs index f5767f58579..94a2f5c64c8 100644 --- a/Lib9c/Action/RuneEnhancement.cs +++ b/Lib9c/Action/RuneEnhancement.cs @@ -16,6 +16,7 @@ using Nekoyume.Module; using Nekoyume.TableData; using Nekoyume.TableData.Rune; +using Nekoyume.TableData.Summon; namespace Nekoyume.Action { @@ -121,7 +122,14 @@ public override IWorld Execute(IActionContext context) $"[{nameof(RuneEnhancement)}] my avatar address : {AvatarAddress}"); } - states.TryGetPatchedSheet(out var boostScheduleSheet); + states.TryGetPatchedSheet(out var patchedBoostScheduleSheet); + var boostScheduleSheet = RuneHelper.ResolveEnhancementCostBoost( + patchedBoostScheduleSheet, + runeState.RuneId, + context.BlockIndex, + () => states.TryGetSheet(out var runeSummonSheet) + ? runeSummonSheet + : null); var random = context.GetRandom(); if (!RuneHelper.TryEnhancement(runeState.Level, costRow, random, TryCount, diff --git a/Lib9c/Helper/RuneHelper.cs b/Lib9c/Helper/RuneHelper.cs index 72b39e17a9d..3c1898d1579 100644 --- a/Lib9c/Helper/RuneHelper.cs +++ b/Lib9c/Helper/RuneHelper.cs @@ -10,6 +10,7 @@ using Nekoyume.Model.State; using Nekoyume.TableData; using Nekoyume.TableData.Rune; +using Nekoyume.TableData.Summon; namespace Nekoyume.Helper { @@ -41,9 +42,10 @@ public static FungibleAssetValue ToFungibleAssetValue( /// The number of tries. /// The level-ups and the summed cost. /// - /// The patched , or null when the chain has none. - /// Adjusts each try's crystal and rune stone cost; see + /// The sheet returns for this rune, or + /// null for none. Adjusts each try's crystal and rune stone cost; see /// . + /// Passing the patched sheet directly skips the summonable-rune check. /// /// /// The block being evaluated, against which rows are @@ -90,8 +92,74 @@ public static bool TryEnhancement( } /// - /// Applies the and - /// rows active at + /// Decides whether a rune's enhancement cost may be adjusted at all, and returns the sheet + /// to pass to and + /// . + /// + /// + /// The patched , or null when the chain has none. + /// + /// The rune being enhanced. + /// The block being evaluated. + /// + /// Loads the , returning null when it is absent or + /// unreadable. Called only when an enhancement cost row is active at + /// , so that evaluation without one reads no extra state. + /// + /// + /// when a + /// or + /// row is + /// active at and is a recipe id of + /// any group; otherwise null, meaning no adjustment. + /// + /// + /// The summon pool is derived from the sheet rather than listed, so the adjustment + /// follows later changes to it. A recipe counts whatever its ratio. A missing + /// means no rune is summonable, never an error. + /// + public static BoostScheduleSheet? ResolveEnhancementCostBoost( + BoostScheduleSheet? boostScheduleSheet, + int runeId, + long blockIndex, + Func getRuneSummonSheet) + { + if (boostScheduleSheet?.OrderedList is null || + !boostScheduleSheet.OrderedList.Any(row => IsActiveEnhancementCostRow(row, blockIndex))) + { + return null; + } + + var runeSummonSheet = getRuneSummonSheet(); + if (runeSummonSheet?.OrderedList is null) + { + return null; + } + + foreach (var summonRow in runeSummonSheet.OrderedList) + { + foreach (var (recipeId, _) in summonRow.Recipes) + { + if (recipeId == runeId) + { + return boostScheduleSheet; + } + } + } + + return null; + } + + private static bool IsActiveEnhancementCostRow(BoostScheduleSheet.Row row, long blockIndex) => + row.IsValid && + (row.Target == BoostScheduleSheet.Targets.SummonRuneEnhancementCrystalCost || + row.Target == BoostScheduleSheet.Targets.SummonRuneEnhancementRuneStoneCost) && + row.StartBlockIndex <= blockIndex && + blockIndex < row.EndBlockIndex; + + /// + /// Applies the and + /// rows active at /// to the cost of one try that reaches /// . /// @@ -100,7 +168,9 @@ public static bool TryEnhancement( /// The level the try attempts to reach, matched against target_id. /// /// - /// The patched , or null when the chain has none. + /// The sheet returns for the rune, or + /// null for none. Passing the patched sheet directly skips the summonable-rune + /// check. /// /// The block being evaluated. /// @@ -111,7 +181,8 @@ public static bool TryEnhancement( /// /// /// Callers showing a cost — or a maximum try count — should pass every per-level cost - /// through this so that they agree with . + /// through this, with the sheet from , so that + /// they agree with . /// public static RuneCostSheet.RuneCostData ApplyEnhancementCostBoost( RuneCostSheet.RuneCostData cost, @@ -125,11 +196,11 @@ public static RuneCostSheet.RuneCostData ApplyEnhancementCostBoost( } var crystalBoost = boostScheduleSheet.FindActive( - BoostScheduleSheet.Targets.RuneEnhancementCrystalCost, + BoostScheduleSheet.Targets.SummonRuneEnhancementCrystalCost, targetLevel, blockIndex); var runeStoneBoost = boostScheduleSheet.FindActive( - BoostScheduleSheet.Targets.RuneEnhancementRuneStoneCost, + BoostScheduleSheet.Targets.SummonRuneEnhancementRuneStoneCost, targetLevel, blockIndex); if (crystalBoost is null && runeStoneBoost is null) diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index 45d8d0cfe76..faf116e95fe 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -99,19 +99,29 @@ public static class Targets public const string CostumeSummonGuarantee = "COSTUME_SUMMON_GUARANTEE"; /// - /// Crystal cost of one RuneEnhancement try, per RuneCostSheet level. + /// Crystal cost of one RuneEnhancement try, per RuneCostSheet level, for + /// summonable runes only: a rune whose id is a recipe id of any + /// RuneSummonSheet group, whatever its ratio. Other runes are never adjusted. + /// The summon pool is read from the chain when the try is evaluated, so a rune added to + /// or removed from RuneSummonSheet gains or loses the adjustment with it. + /// /// target_id is the rune level the try attempts to reach — the current level - /// plus one — for every rune id, so 1~50 discounts the tries from level 0 up to - /// level 50. The adjusted cost never exceeds the sheet's cost and never drops below 1 - /// while the sheet's cost is positive. NCG cost and success rate are not adjustable. + /// plus one — so 1~50 adjusts the tries from level 0 up to level 50. It cannot + /// narrow the row to particular rune ids. The adjusted cost never exceeds the sheet's + /// cost and never drops below 1 while the sheet's cost is positive. NCG cost and + /// success rate are not adjustable. + /// /// - public const string RuneEnhancementCrystalCost = "RUNE_ENHANCEMENT_CRYSTAL_COST"; + public const string SummonRuneEnhancementCrystalCost = + "SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST"; /// - /// Rune stone cost of one RuneEnhancement try, per RuneCostSheet level. - /// target_id and bounds as in . + /// Rune stone cost of one RuneEnhancement try, per RuneCostSheet level, + /// for summonable runes only. Scope, target_id and bounds as in + /// . /// - public const string RuneEnhancementRuneStoneCost = "RUNE_ENHANCEMENT_RUNE_STONE_COST"; + public const string SummonRuneEnhancementRuneStoneCost = + "SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST"; /// /// Every target above, for tooling that warns about rows no running code applies. @@ -121,8 +131,8 @@ public static class Targets EquipmentSummonGuarantee, RuneSummonGuarantee, CostumeSummonGuarantee, - RuneEnhancementCrystalCost, - RuneEnhancementRuneStoneCost, + SummonRuneEnhancementCrystalCost, + SummonRuneEnhancementRuneStoneCost, }; } From 2a2b852cb0157e5430d21023d5084d5ae23605cc Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Mon, 12 Oct 2026 10:39:21 +0900 Subject: [PATCH 30/41] =?UTF-8?q?refactor:=20=EB=A3=AC=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20=EB=B6=80=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EC=84=9D=EB=90=9C=20=ED=83=80=EC=9E=85=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=EB=A7=8C=20=EB=B0=9B=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TryEnhancement / ApplyEnhancementCostBoost 가 패치된 BoostScheduleSheet 를 그대로 받으면 소환 룬 판정을 건너뛸 수 있었다. 클라이언트가 그렇게 쓰면 비소환 룬에 할인가를 보여 주고, 실제 tx 는 NotEnoughFungibleAssetValueException 으로 실패한다. - RuneEnhancementCostBoost(sealed, 생성자 internal) 를 도입했다. 인스턴스는 RuneHelper.ResolveEnhancementCostBoost 만 만들 수 있고, 클라이언트는 별도 asmdef 라 직접 만들 수 없다. - TryEnhancement(..., RuneEnhancementCostBoost? boost = null, long blockIndex = 0) 와 ApplyEnhancementCostBoost 는 이 타입만 받는다. 시트를 직접 받는 공개 경로는 없다. 5인자 호출부는 그대로 컴파일되고 부스트 없는 경로도 동일하다. - Targets 문서: "소환 룬" 은 RuneSummonSheet 어느 행의 recipe 든 등장하는 룬이며, 비율이나 그 그룹의 현재 판매 여부와 무관하다. - 테스트: 인라인 RuneSummonSheet 로 비율 0 recipe 와 두 번째 그룹에만 있는 룬을 추가했다. "첫 그룹만" / "비율 > 0 만" 변이를 각각 잡는다. Co-Authored-By: Claude Opus 5.5 --- .Lib9c.Tests/Action/RuneHelperTest.cs | 61 +++++++++++++++++++----- Lib9c/Action/RuneEnhancement.cs | 4 +- Lib9c/Helper/RuneEnhancementCostBoost.cs | 22 +++++++++ Lib9c/Helper/RuneHelper.cs | 47 +++++++++--------- Lib9c/TableData/BoostScheduleSheet.cs | 3 +- 5 files changed, 99 insertions(+), 38 deletions(-) create mode 100644 Lib9c/Helper/RuneEnhancementCostBoost.cs diff --git a/.Lib9c.Tests/Action/RuneHelperTest.cs b/.Lib9c.Tests/Action/RuneHelperTest.cs index 34213c536eb..9c6463a7a0f 100644 --- a/.Lib9c.Tests/Action/RuneHelperTest.cs +++ b/.Lib9c.Tests/Action/RuneHelperTest.cs @@ -84,7 +84,7 @@ public void ApplyEnhancementCostBoost_FollowsTiersByTargetLevel( { var cost = new RuneCostSheet.RuneCostData(level, level, 240, 20000, 50, 7000); - var boosted = RuneHelper.ApplyEnhancementCostBoost(cost, level, TieredSheet(), 150); + var boosted = RuneHelper.ApplyEnhancementCostBoost(cost, level, Resolve(TieredSheet()), 150); Assert.Equal(expectedCrystal, boosted.CrystalQuantity); Assert.Equal(expectedRuneStone, boosted.RuneStoneQuantity); @@ -101,7 +101,7 @@ public void ApplyEnhancementCostBoost_OutsideTheSchedule_ReturnsTheSameCost(long { var cost = new RuneCostSheet.RuneCostData(1, 1, 240, 20000, 50, 7000); - Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 1, TieredSheet(), blockIndex)); + Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 1, Resolve(TieredSheet()), blockIndex)); Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 1, null, 150)); } @@ -110,10 +110,11 @@ public void ApplyEnhancementCostBoost_IgnoresOtherTargets() { var sheet = CreateBoostSheet( "1,RUNE_SUMMON_GUARANTEE,*,MUL,0.5,0,10", - "2,SUMMON_RUNE_ENHANCEMENT_NCG_COST,*,MUL,0.5,0,10"); + "2,SUMMON_RUNE_ENHANCEMENT_NCG_COST,*,MUL,0.5,0,10", + "3,SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST,100~200,MUL,0.5,0,10"); var cost = new RuneCostSheet.RuneCostData(1, 1, 240, 20000, 50, 7000); - Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 1, sheet, 5)); + Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 1, Resolve(sheet, 5), 5)); } [Fact] @@ -122,7 +123,7 @@ public void ApplyEnhancementCostBoost_AdjustsOnlyTheScheduledCurrency() var sheet = CreateBoostSheet("1,SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST,1~10,MUL,0.5,0,10"); var cost = new RuneCostSheet.RuneCostData(1, 1, 240, 20000, 50, 7000); - var boosted = RuneHelper.ApplyEnhancementCostBoost(cost, 1, sheet, 5); + var boosted = RuneHelper.ApplyEnhancementCostBoost(cost, 1, Resolve(sheet, 5), 5); Assert.Equal(120, boosted.RuneStoneQuantity); Assert.Equal(20000, boosted.CrystalQuantity); @@ -152,7 +153,7 @@ public void TryEnhancement_AppliesBoostPerTriedLevelAndKeepsTheRandomDraws() boostRandom, tryCount, out var boostResult, - TieredSheet(), + Resolve(TieredSheet()), 150)); // Same draws, same outcome, same NCG. @@ -202,9 +203,12 @@ public void TryEnhancement_WithoutBoost_IsUnchanged() RuneHelper.TryEnhancement(48, costRow, expectedRandom, 4, out var expected); var emptyRandom = new TestRandom(3); - RuneHelper.TryEnhancement(48, costRow, emptyRandom, 4, out var withEmptySheet, new BoostScheduleSheet(), 150); + var empty = Resolve(new BoostScheduleSheet()); + Assert.Null(empty); + RuneHelper.TryEnhancement(48, costRow, emptyRandom, 4, out var withEmptySheet, empty, 150); var outsideRandom = new TestRandom(3); - RuneHelper.TryEnhancement(48, costRow, outsideRandom, 4, out var outsideSchedule, TieredSheet(), 250); + var resolved = Resolve(TieredSheet()); + RuneHelper.TryEnhancement(48, costRow, outsideRandom, 4, out var outsideSchedule, resolved, 250); Assert.Equal(expected.ToString(), withEmptySheet.ToString()); Assert.Equal(expected.ToString(), outsideSchedule.ToString()); @@ -220,15 +224,29 @@ public void ResolveEnhancementCostBoost_OnlyForSummonableRunes( long blockIndex, bool expected) { - var sheet = TieredSheet(); - var resolved = RuneHelper.ResolveEnhancementCostBoost( - sheet, + TieredSheet(), runeId, blockIndex, () => _tableSheets.RuneSummonSheet); - Assert.Equal(expected ? sheet : null, resolved); + Assert.Equal(expected, resolved is not null); + } + + [Theory] + [InlineData(101, true)] // first group + [InlineData(102, true)] // first group, ratio 0: listed, so still summonable + [InlineData(104, true)] // only in a later group + [InlineData(999, false)] // in no group + public void ResolveEnhancementCostBoost_LooksAtEveryRecipeOfEveryGroup(int runeId, bool expected) + { + var resolved = RuneHelper.ResolveEnhancementCostBoost( + TieredSheet(), + runeId, + 150, + InlineRuneSummonSheet); + + Assert.Equal(expected, resolved is not null); } [Fact] @@ -278,6 +296,25 @@ public void TryEnhancement_NonSummonableRune_IsUnchanged() Assert.Equal(expected.ToString(), actual.ToString()); } + /// + /// Resolves for a rune lists. + /// + private static RuneEnhancementCostBoost Resolve(BoostScheduleSheet sheet, long blockIndex = 150) => + RuneHelper.ResolveEnhancementCostBoost(sheet, 101, blockIndex, InlineRuneSummonSheet); + + /// + /// Two groups: rune 102 is listed with ratio 0, rune 104 only in the second group. + /// + private static RuneSummonSheet InlineRuneSummonSheet() + { + var sheet = new RuneSummonSheet(); + sheet.Set( + "groupID,cost_material,cost_material_count,cost_ncg,recipe1ID,recipe1ratio,recipe2ID,recipe2ratio\n" + + "1,600201,20,0,101,100,102,0\n" + + "2,600202,20,0,103,100,104,100\n"); + return sheet; + } + /// /// The rotation event E3 curve, scheduled for blocks [100, 200). /// diff --git a/Lib9c/Action/RuneEnhancement.cs b/Lib9c/Action/RuneEnhancement.cs index 94a2f5c64c8..c33d23b36e9 100644 --- a/Lib9c/Action/RuneEnhancement.cs +++ b/Lib9c/Action/RuneEnhancement.cs @@ -123,7 +123,7 @@ public override IWorld Execute(IActionContext context) } states.TryGetPatchedSheet(out var patchedBoostScheduleSheet); - var boostScheduleSheet = RuneHelper.ResolveEnhancementCostBoost( + var costBoost = RuneHelper.ResolveEnhancementCostBoost( patchedBoostScheduleSheet, runeState.RuneId, context.BlockIndex, @@ -133,7 +133,7 @@ public override IWorld Execute(IActionContext context) var random = context.GetRandom(); if (!RuneHelper.TryEnhancement(runeState.Level, costRow, random, TryCount, - out var levelUpResult, boostScheduleSheet, context.BlockIndex)) + out var levelUpResult, costBoost, context.BlockIndex)) { // Rune cost not found while level up throw new RuneCostDataNotFoundException( diff --git a/Lib9c/Helper/RuneEnhancementCostBoost.cs b/Lib9c/Helper/RuneEnhancementCostBoost.cs new file mode 100644 index 00000000000..a9e767147fc --- /dev/null +++ b/Lib9c/Helper/RuneEnhancementCostBoost.cs @@ -0,0 +1,22 @@ +#nullable enable +using Nekoyume.TableData; + +namespace Nekoyume.Helper +{ + /// + /// A already checked to apply to one rune's enhancement cost: + /// an enhancement cost row is active and the rune is summonable. Only + /// creates one, so a cost can never be + /// adjusted by handing the raw patched sheet to or + /// and skipping that check. + /// + public sealed class RuneEnhancementCostBoost + { + internal RuneEnhancementCostBoost(BoostScheduleSheet boostScheduleSheet) + { + BoostScheduleSheet = boostScheduleSheet; + } + + internal BoostScheduleSheet BoostScheduleSheet { get; } + } +} diff --git a/Lib9c/Helper/RuneHelper.cs b/Lib9c/Helper/RuneHelper.cs index 3c1898d1579..030688a5ee2 100644 --- a/Lib9c/Helper/RuneHelper.cs +++ b/Lib9c/Helper/RuneHelper.cs @@ -41,15 +41,14 @@ public static FungibleAssetValue ToFungibleAssetValue( /// The random source; one draw per try. /// The number of tries. /// The level-ups and the summed cost. - /// - /// The sheet returns for this rune, or - /// null for none. Adjusts each try's crystal and rune stone cost; see - /// . - /// Passing the patched sheet directly skips the summonable-rune check. + /// + /// What returned for this rune, or null + /// for none. Adjusts each try's crystal and rune stone cost; see + /// . /// /// - /// The block being evaluated, against which rows are - /// matched. Ignored when is null. + /// The block being evaluated, against which rows are matched. + /// Ignored when is null. /// /// /// false when a try reaches a level has no cost for. @@ -60,7 +59,7 @@ public static bool TryEnhancement( IRandom random, int tryCount, out RuneEnhancement.LevelUpResult levelUpResult, - BoostScheduleSheet? boostScheduleSheet = null, + RuneEnhancementCostBoost? boost = null, long blockIndex = 0) { levelUpResult = new RuneEnhancement.LevelUpResult(); @@ -75,7 +74,7 @@ public static bool TryEnhancement( return false; } - cost = ApplyEnhancementCostBoost(cost, targetLevel, boostScheduleSheet, blockIndex); + cost = ApplyEnhancementCostBoost(cost, targetLevel, boost, blockIndex); // Cost burns in every try levelUpResult.NcgCost += cost.NcgQuantity; @@ -92,9 +91,8 @@ public static bool TryEnhancement( } /// - /// Decides whether a rune's enhancement cost may be adjusted at all, and returns the sheet - /// to pass to and - /// . + /// Decides whether a rune's enhancement cost may be adjusted at all, and returns what to + /// pass to and . /// /// /// The patched , or null when the chain has none. @@ -107,7 +105,8 @@ public static bool TryEnhancement( /// , so that evaluation without one reads no extra state. /// /// - /// when a + /// A over + /// when a /// or /// row is /// active at and is a recipe id of @@ -115,10 +114,11 @@ public static bool TryEnhancement( /// /// /// The summon pool is derived from the sheet rather than listed, so the adjustment - /// follows later changes to it. A recipe counts whatever its ratio. A missing + /// follows later changes to it. A recipe counts whatever its ratio and whether or not its + /// group is currently sold. A missing /// means no rune is summonable, never an error. /// - public static BoostScheduleSheet? ResolveEnhancementCostBoost( + public static RuneEnhancementCostBoost? ResolveEnhancementCostBoost( BoostScheduleSheet? boostScheduleSheet, int runeId, long blockIndex, @@ -142,7 +142,7 @@ public static bool TryEnhancement( { if (recipeId == runeId) { - return boostScheduleSheet; + return new RuneEnhancementCostBoost(boostScheduleSheet); } } } @@ -167,10 +167,9 @@ private static bool IsActiveEnhancementCostRow(BoostScheduleSheet.Row row, long /// /// The level the try attempts to reach, matched against target_id. /// - /// - /// The sheet returns for the rune, or - /// null for none. Passing the patched sheet directly skips the summonable-rune - /// check. + /// + /// What returned for the rune, or null + /// for none. /// /// The block being evaluated. /// @@ -181,20 +180,22 @@ private static bool IsActiveEnhancementCostRow(BoostScheduleSheet.Row row, long /// /// /// Callers showing a cost — or a maximum try count — should pass every per-level cost - /// through this, with the sheet from , so that + /// through this, with the boost from , so that /// they agree with . /// public static RuneCostSheet.RuneCostData ApplyEnhancementCostBoost( RuneCostSheet.RuneCostData cost, int targetLevel, - BoostScheduleSheet? boostScheduleSheet, + RuneEnhancementCostBoost? boost, long blockIndex) { - if (boostScheduleSheet is null) + if (boost is null) { return cost; } + var boostScheduleSheet = boost.BoostScheduleSheet; + var crystalBoost = boostScheduleSheet.FindActive( BoostScheduleSheet.Targets.SummonRuneEnhancementCrystalCost, targetLevel, diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index faf116e95fe..243476d920b 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -101,7 +101,8 @@ public static class Targets /// /// Crystal cost of one RuneEnhancement try, per RuneCostSheet level, for /// summonable runes only: a rune whose id is a recipe id of any - /// RuneSummonSheet group, whatever its ratio. Other runes are never adjusted. + /// RuneSummonSheet row, whatever its ratio and whether or not that group is + /// currently sold. Other runes are never adjusted. /// The summon pool is read from the chain when the try is evaluated, so a rune added to /// or removed from RuneSummonSheet gains or loses the adjustment with it. /// From 06d5d340c071451c1d2fbea9c05f67e613e59618 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Mon, 12 Oct 2026 10:35:26 +0900 Subject: [PATCH 31/41] =?UTF-8?q?feat:=20=EC=8A=A4=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=B6=80=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=EC=84=9C=201=EA=B0=9C=20=EB=93=9C=EB=9E=8D?= =?UTF-8?q?=EC=9D=80=202=EA=B0=9C=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STAGE_ITEM_REWARD 의 MUL 값이 1 보다 크면, 클리어 1회에 1개만 뽑힌 아이템은 최소 2개를 준다(max(floor(1 × value), 2)). 대부분의 스테이지가 아이템을 클리어당 1개씩 떨궈서 MUL 1.5 같은 소수 배율이 아무 효과 없던 문제를 기획(재호) 승인으로 보정한다. - 2개 이상 뽑힌 아이템은 기존 내림 그대로(MUL 1.2 × 2 = 2). 일반적인 "최소 +1" 이 아니다. - MUL ≤ 1, ADD, FAV(STAGE_FAV_REWARD) 는 변경 없음. 10배 상한도 그대로. - 계산을 StageSimulator.GetBoostedItemRewardCount 한 곳에 두고 SplitItemRewardBoost 가 쓰므로 ApplyItemRewardBoost, HackAndSlashSweep.GetRewardItems(5·6인자) 가 모두 같은 규칙을 따른다. HackAndSlash·소탕·클라 재생 결과가 일치한다. - 난수 소비는 여전히 변하지 않는다(뽑은 뒤 수량만 조정). Co-Authored-By: Claude Opus 5.5 --- .Lib9c.Tests/Action/HackAndSlashSweepTest.cs | 23 +++++++++- .Lib9c.Tests/Model/StageRewardBoostTest.cs | 47 +++++++++++++++++++- Lib9c/Action/HackAndSlashSweep.cs | 4 +- Lib9c/Battle/StageSimulator.cs | 35 ++++++++++++--- Lib9c/TableData/BoostScheduleSheet.cs | 14 +++--- 5 files changed, 110 insertions(+), 13 deletions(-) diff --git a/.Lib9c.Tests/Action/HackAndSlashSweepTest.cs b/.Lib9c.Tests/Action/HackAndSlashSweepTest.cs index 033bc1c8c11..e56737bd897 100644 --- a/.Lib9c.Tests/Action/HackAndSlashSweepTest.cs +++ b/.Lib9c.Tests/Action/HackAndSlashSweepTest.cs @@ -1291,13 +1291,21 @@ public void Execute_AppliesStageRewardBoostPerClear(string op, string value) foreach (var group in drawn.GroupBy(item => item.Id)) { perClear.TryGetValue(group.Key, out var boosted); - perClear[group.Key] = boosted + itemBoost.Apply(group.Count()); + perClear[group.Key] = boosted + ExpectedBoostedCount(op, value, itemBoost, group.Count()); drawnTotal.TryGetValue(group.Key, out var total); drawnTotal[group.Key] = total + group.Count(); } } Assert.True(playCount > 1, "the sweep should cover several clears"); + if (op == "MUL") + { + // Stage 1 drops one 303000 per clear; MUL 1.5 grants 2 of a single drop. + Assert.Equal(new[] { 303000 }, drawnTotal.Keys); + Assert.Equal(playCount, drawnTotal[303000]); + Assert.Equal(2 * playCount, perClear[303000]); + } + Assert.Contains(perClear, pair => pair.Value != itemBoost.Apply(drawnTotal[pair.Key])); foreach (var id in perClear.Keys.Union(patched.Materials.Keys)) @@ -1459,6 +1467,19 @@ public void GetRewardItems_IsUnchangedWithoutARow() boosted.Select(item => item.Id)); } + /// + /// The per-clear item rule planning set, written out independently of the code under test: + /// plain floor, except that a MUL above 1 turns a single drop into 2, capped at + /// times the drawn count. + /// + private static int ExpectedBoostedCount(string op, string value, BoostScheduleSheet.Row row, int count) + { + var applied = row.Apply(count); + var multiplier = decimal.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + var expected = op == "MUL" && multiplier > 1m && count == 1 ? Math.Max(applied, 2) : applied; + return Math.Min(expected, count * StageSimulator.MaxItemRewardBoostFactor); + } + private static List<(int Id, bool Tradable, int Count)> Stacks(AvatarState avatarState) => avatarState.inventory.Items .GroupBy(i => (i.item.Id, Tradable: i.item is ITradableItem)) diff --git a/.Lib9c.Tests/Model/StageRewardBoostTest.cs b/.Lib9c.Tests/Model/StageRewardBoostTest.cs index 95a6c6c369e..1a255348aba 100644 --- a/.Lib9c.Tests/Model/StageRewardBoostTest.cs +++ b/.Lib9c.Tests/Model/StageRewardBoostTest.cs @@ -33,7 +33,7 @@ public void ApplyItemRewardBoost_ReturnsTheSameListWithoutARow() [Theory] [InlineData("MUL", "2", 3, 1, 6, 2)] // exactly twice each drawn item - [InlineData("MUL", "1.5", 3, 1, 4, 1)] // floor per distinct item: 1 × 1.5 stays 1 + [InlineData("MUL", "1.5", 3, 1, 4, 2)] // floor per distinct item, but a single drop becomes 2 [InlineData("ADD", "1", 3, 1, 4, 2)] // one more of each distinct item [InlineData("MUL", "0.5", 3, 1, 1, 0)] // a multiplier below 1 takes items away [InlineData("MUL", "100", 3, 1, 30, 10)] // capped at MaxItemRewardBoostFactor @@ -100,6 +100,51 @@ public void SplitItemRewardBoost_LeavesEverythingAloneWithoutARow() Assert.Empty(extraCounts); } + [Theory] + [InlineData("MUL", "1.2", 1, 2)] // a single drop becomes at least 2 under MUL > 1 + [InlineData("MUL", "1.01", 1, 2)] // however small the excess: effectively 100% here + [InlineData("MUL", "1.00", 1, 1)] + [InlineData("MUL", "1.5", 3, 4)] + [InlineData("MUL", "1.5", 1, 2)] + [InlineData("MUL", "2", 1, 2)] + [InlineData("MUL", "3", 1, 3)] + [InlineData("MUL", "1.2", 2, 2)] // not a general "at least one more": plain floor + [InlineData("MUL", "1.5", 2, 3)] + [InlineData("MUL", "1", 1, 1)] + [InlineData("MUL", "0.5", 1, 0)] // dropped as before + [InlineData("ADD", "1", 1, 2)] // ADD is unchanged + [InlineData("ADD", "0", 1, 1)] + [InlineData("ADD", "-1", 1, 0)] + [InlineData("MUL", "100", 1, 10)] // still capped at MaxItemRewardBoostFactor + public void GetBoostedItemRewardCount_TurnsASingleDropIntoTwoUnderMul( + string op, + string value, + int count, + int expected) + { + var boost = StageRewardBoostFixture.Row(Item, op, decimal.Parse(value, System.Globalization.CultureInfo.InvariantCulture)); + + Assert.Equal(expected, StageSimulator.GetBoostedItemRewardCount(boost, count)); + } + + [Fact] + public void ApplyItemRewardBoost_BoostsSingleAndDoubleDropsOfOneClearDifferently() + { + // Under MUL 1.5 one clear grants 2 of an item drawn once and 3 of an item drawn twice. + var rows = MaterialRows(); + var rewards = new[] { rows[0], rows[1], rows[1] } + .Select(row => (ItemBase)ItemFactory.CreateMaterial(row)) + .ToList(); + + var boosted = StageSimulator.ApplyItemRewardBoost( + rewards, + StageRewardBoostFixture.Row(Item, "MUL", 1.5m), + _tableSheets.MaterialItemSheet); + + Assert.Equal(2, boosted.Count(item => item.Id == rows[0].Id)); + Assert.Equal(3, boosted.Count(item => item.Id == rows[1].Id)); + } + [Fact] public void ApplyItemRewardBoost_AddsCirclesAsTradableMaterials() { diff --git a/Lib9c/Action/HackAndSlashSweep.cs b/Lib9c/Action/HackAndSlashSweep.cs index 43a32a684aa..1aa42690cac 100644 --- a/Lib9c/Action/HackAndSlashSweep.cs +++ b/Lib9c/Action/HackAndSlashSweep.cs @@ -525,7 +525,9 @@ public override IWorld Execute(IActionContext context) /// /// The row adjusting each clear's items, or null /// for none. Applied per clear exactly as HackAndSlash applies it; see - /// . It never changes the random draws. + /// and + /// . It never changes the random + /// draws. /// A caller replaying a sweep must pass what the action resolved. /// /// diff --git a/Lib9c/Battle/StageSimulator.cs b/Lib9c/Battle/StageSimulator.cs index 3f25ffb0d03..617332ad217 100644 --- a/Lib9c/Battle/StageSimulator.cs +++ b/Lib9c/Battle/StageSimulator.cs @@ -266,13 +266,15 @@ public StageSimulator(IRandom random, /// /// itself when is /// null; otherwise a new list, ordered by item id, in which each distinct item - /// drawn n times appears (n) + /// drawn n times appears (row, n) /// times, at most × n. /// /// /// Draws nothing, so the random sequence is the same as without a row. MUL 2 /// grants exactly twice each drawn item; ADD 1 one more of each distinct item; a - /// multiplier below 1 takes items away. The count can exceed the stage's + /// multiplier below 1 takes items away. A MUL above 1 turns an item drawn once + /// into at least 2, so MUL 1.5 grants 2 of a single drop (and 3 of a double). + /// The count can exceed the stage's /// DropItemMax, which only limits the draws. Added items are created the way /// creates them, so a circle is tradable as usual. /// Equivalent to followed by @@ -335,9 +337,7 @@ public static List SplitItemRewardBoost( { var drawn = group.ToList(); var count = drawn.Count; - var boosted = (int)Math.Min( - itemRewardBoost.Apply(count), - (long)count * MaxItemRewardBoostFactor); + var boosted = GetBoostedItemRewardCount(itemRewardBoost, count); if (boosted <= count) { kept.AddRange(drawn.Take(boosted)); @@ -352,6 +352,31 @@ public static List SplitItemRewardBoost( return kept; } + /// + /// The boosted count of an item drawn times in one clear. + /// + /// The row to apply. + /// How many times the item was drawn in the clear. + /// + /// (), except that a + /// above 1 turns an item drawn once into + /// at least 2, so that a fractional multiplier still rewards the single drops most stages + /// make. A count of 2 or more keeps the plain floor (MUL 1.2 on 2 stays 2). Capped + /// at × . + /// + public static int GetBoostedItemRewardCount(BoostScheduleSheet.Row itemRewardBoost, int count) + { + var boosted = itemRewardBoost.Apply(count); + if (count == 1 && + itemRewardBoost.Op == BoostScheduleSheet.Operation.Mul && + itemRewardBoost.Value > 1m) + { + boosted = Math.Max(boosted, 2); + } + + return (int)Math.Min(boosted, (long)count * MaxItemRewardBoostFactor); + } + /// /// Creates the items counted, one object per unit, /// for a caller that needs objects (a battle's drop log, a result view). diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index 5b730131984..929a1660667 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -106,17 +106,21 @@ public static class Targets /// Applied per clear to the count of each distinct item after the items are drawn, so /// the random draws are the same as without the row: MUL 2 grants exactly /// twice each drawn item and ADD 1 one more of each. See - /// StageSimulator.ApplyItemRewardBoost. + /// StageSimulator.GetBoostedItemRewardCount. /// /// /// Boosted items are ordinary rewards everywhere else too: they count toward /// collect quest progress, and a circle stays tradable. /// /// - /// Rounding is per item id per clear, so a MUL between 1 and 2 has no effect on - /// an item drawn once in a clear — which is how most stages drop. Prefer ADD or - /// an integer MUL for items; amounts are large, so a - /// fractional MUL scales them as expected. + /// Rounding is down, per item id per clear, with one exception: a MUL above 1 + /// turns an item drawn once in a clear (how most stages drop) into at least 2, so + /// MUL 1.5 grants 2 of a single drop. A count of 2 or more keeps the plain floor + /// (MUL 1.2 on 2 stays 2, MUL 1.5 on 2 becomes 3); this is not a general + /// "at least one more". ADD and a MUL of 1 or below are unaffected. + /// Beware that any MUL just above 1 (e.g. MUL 1.01) also turns a single + /// drop into 2 — effectively a 100% boost for those items, however small the value. + /// amounts are large and always plainly floored. /// /// public const string StageItemReward = "STAGE_ITEM_REWARD"; From 16f6a1822edf2f3a2e06b36728f2082970d4128e Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Mon, 12 Oct 2026 10:56:39 +0900 Subject: [PATCH 32/41] =?UTF-8?q?test:=20target=5Fid=20=EA=B5=AC=EA=B0=84?= =?UTF-8?q?=20=ED=8C=8C=EC=8B=B1=20=EA=B2=BD=EA=B3=84=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "~" 단독, "*~5", int 를 넘는 끝값, 전각 물결표(IME) 는 무효로, "1~1" 과 끝값 주변 공백은 유효로 읽히는 것을 테스트로 고정한다. Co-Authored-By: Claude Opus 5.5 --- .Lib9c.Tests/TableData/BoostScheduleSheetTest.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs b/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs index e2332a9746c..3ed88281763 100644 --- a/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs +++ b/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs @@ -79,6 +79,18 @@ public void TargetIdRangeIsInclusive(int targetId, bool expected) Assert.Equal(expected, sheet.FindActive(Target, targetId, 150) is not null); } + [Theory] + [InlineData("1~1", 1, true)] // a range of one id + [InlineData("1~1", 2, false)] + [InlineData(" 1 ~ 50 ", 50, true)] // blanks around the ends are allowed + public void TargetIdRangeAcceptsDegenerateAndSpacedForms(string targetId, int id, bool expected) + { + var sheet = Parse($"1,{Target},{targetId},MUL,0.6,100,200\n"); + + Assert.True(sheet[1].IsValid); + Assert.Equal(expected, sheet.FindActive(Target, id, 150) is not null); + } + [Fact] public void TargetIdRangesSplitATargetIntoTiers() { @@ -104,6 +116,10 @@ public void TargetIdRangesSplitATargetIntoTiers() [InlineData("1,TARGET,~50,MUL,1.5,100,200")] [InlineData("1,TARGET,1~20~50,MUL,1.5,100,200")] [InlineData("1,TARGET,1-50,MUL,1.5,100,200")] // "-" is not the separator + [InlineData("1,TARGET,~,MUL,1.5,100,200")] // separator only + [InlineData("1,TARGET,*~5,MUL,1.5,100,200")] // "*" is not a range end + [InlineData("1,TARGET,1~99999999999,MUL,1.5,100,200")] // beyond int + [InlineData("1,TARGET,1~50,MUL,1.5,100,200")] // full-width tilde from an IME [InlineData("1,TARGET,10001,POW,1.5,100,200")] // unknown op [InlineData("1,TARGET,10001,mul,1.5,100,200")] // op is case sensitive [InlineData("1,TARGET,10001,MUL,1.5x,100,200")] // value not a number From fb55de0a385ddf9fbfa94d2701889bb1e1d1418b Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Mon, 12 Oct 2026 11:01:18 +0900 Subject: [PATCH 33/41] =?UTF-8?q?fix:=20=EB=A3=AC=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?=EB=B6=80=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=ED=95=B4=EC=84=9D?= =?UTF-8?q?=ED=95=9C=20=EB=A3=AC=EC=97=90=EB=A7=8C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuneEnhancementCostBoost 가 시트만 들고 있어서, 소환 룬으로 해석한 부스트를 다른(비소환) 룬의 비용에 넘겨도 할인이 적용됐다. 룬 선택이 바뀌어도 부스트를 캐시해 두는 UI 라면 래퍼 타입을 둔 의미가 없어진다. - RuneEnhancementCostBoost 에 해석 시점의 RuneId 를 저장한다(Resolve 가 설정). - ApplyEnhancementCostBoost(cost, runeId, targetLevel, boost, blockIndex): runeId 가 boost.RuneId 와 다르면 부스트를 무시한다. RuneCostData 에는 룬 id 가 없어서 파라미터로 받는다. - TryEnhancement 는 costRow.RuneId 를 넘긴다. id 가 다르면 throw 하지 않고 무시해 할인 전 비용을 보여 준다(클라이언트 미리보기 경로). 온체인 RuneEnhancement 는 항상 같은 id 로 해석하므로 동작이 같다. - 클래스 문서: 부스트는 해석한 룬만 할인한다는 보장을 명시했다. - 테스트: 10001 로 해석한 부스트를 10036 비용에 넘기면 할인되지 않는다 (TryEnhancement, ApplyEnhancementCostBoost 각각). id 검사를 빼는 변이를 잡는다. Co-Authored-By: Claude Opus 5.5 --- .Lib9c.Tests/Action/RuneHelperTest.cs | 55 +++++++++++++++++++----- Lib9c/Helper/RuneEnhancementCostBoost.cs | 20 ++++++--- Lib9c/Helper/RuneHelper.cs | 17 +++++--- 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/.Lib9c.Tests/Action/RuneHelperTest.cs b/.Lib9c.Tests/Action/RuneHelperTest.cs index 9c6463a7a0f..ba5a7d18bc6 100644 --- a/.Lib9c.Tests/Action/RuneHelperTest.cs +++ b/.Lib9c.Tests/Action/RuneHelperTest.cs @@ -84,7 +84,7 @@ public void ApplyEnhancementCostBoost_FollowsTiersByTargetLevel( { var cost = new RuneCostSheet.RuneCostData(level, level, 240, 20000, 50, 7000); - var boosted = RuneHelper.ApplyEnhancementCostBoost(cost, level, Resolve(TieredSheet()), 150); + var boosted = RuneHelper.ApplyEnhancementCostBoost(cost, 101, level, Resolve(TieredSheet()), 150); Assert.Equal(expectedCrystal, boosted.CrystalQuantity); Assert.Equal(expectedRuneStone, boosted.RuneStoneQuantity); @@ -101,8 +101,8 @@ public void ApplyEnhancementCostBoost_OutsideTheSchedule_ReturnsTheSameCost(long { var cost = new RuneCostSheet.RuneCostData(1, 1, 240, 20000, 50, 7000); - Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 1, Resolve(TieredSheet()), blockIndex)); - Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 1, null, 150)); + Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 101, 1, Resolve(TieredSheet()), blockIndex)); + Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 101, 1, null, 150)); } [Fact] @@ -114,7 +114,7 @@ public void ApplyEnhancementCostBoost_IgnoresOtherTargets() "3,SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST,100~200,MUL,0.5,0,10"); var cost = new RuneCostSheet.RuneCostData(1, 1, 240, 20000, 50, 7000); - Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 1, Resolve(sheet, 5), 5)); + Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 101, 1, Resolve(sheet, 5), 5)); } [Fact] @@ -123,7 +123,7 @@ public void ApplyEnhancementCostBoost_AdjustsOnlyTheScheduledCurrency() var sheet = CreateBoostSheet("1,SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST,1~10,MUL,0.5,0,10"); var cost = new RuneCostSheet.RuneCostData(1, 1, 240, 20000, 50, 7000); - var boosted = RuneHelper.ApplyEnhancementCostBoost(cost, 1, Resolve(sheet, 5), 5); + var boosted = RuneHelper.ApplyEnhancementCostBoost(cost, 101, 1, Resolve(sheet, 5), 5); Assert.Equal(120, boosted.RuneStoneQuantity); Assert.Equal(20000, boosted.CrystalQuantity); @@ -137,10 +137,10 @@ public void TryEnhancement_AppliesBoostPerTriedLevelAndKeepsTheRandomDraws() var costSheet = new RuneCostSheet(); costSheet.Set( "id,level_start,level_end,rune_stone_quantity,crystal_quantity,ncg_quantity,level_up_success_rate\n" + - "1,1,48,1,1,1,10000\n" + - "1,49,50,240,20000,0,5000\n" + - "1,51,52,180,15000,50,5000\n"); - var costRow = costSheet[1]; + "101,1,48,1,1,1,10000\n" + + "101,49,50,240,20000,0,5000\n" + + "101,51,52,180,15000,50,5000\n"); + var costRow = costSheet[101]; const int seed = 7; const int tryCount = 6; @@ -207,7 +207,12 @@ public void TryEnhancement_WithoutBoost_IsUnchanged() Assert.Null(empty); RuneHelper.TryEnhancement(48, costRow, emptyRandom, 4, out var withEmptySheet, empty, 150); var outsideRandom = new TestRandom(3); - var resolved = Resolve(TieredSheet()); + var resolved = RuneHelper.ResolveEnhancementCostBoost( + TieredSheet(), + 10001, + 150, + () => _tableSheets.RuneSummonSheet); + Assert.NotNull(resolved); RuneHelper.TryEnhancement(48, costRow, outsideRandom, 4, out var outsideSchedule, resolved, 250); Assert.Equal(expected.ToString(), withEmptySheet.ToString()); @@ -296,6 +301,36 @@ public void TryEnhancement_NonSummonableRune_IsUnchanged() Assert.Equal(expected.ToString(), actual.ToString()); } + [Fact] + public void ApplyEnhancementCostBoost_IgnoresABoostResolvedForAnotherRune() + { + var boost = Resolve(TieredSheet()); + Assert.NotNull(boost); + Assert.Equal(101, boost.RuneId); + var cost = new RuneCostSheet.RuneCostData(1, 1, 240, 20000, 50, 7000); + + Assert.Same(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 999, 1, boost, 150)); + Assert.NotSame(cost, RuneHelper.ApplyEnhancementCostBoost(cost, 101, 1, boost, 150)); + } + + [Fact] + public void TryEnhancement_IgnoresABoostResolvedForAnotherRune() + { + // Resolved for summonable 10001, then reused with non-summonable 10036's cost row. + var boost = RuneHelper.ResolveEnhancementCostBoost( + TieredSheet(), + 10001, + 150, + () => _tableSheets.RuneSummonSheet); + Assert.NotNull(boost); + var costRow = _tableSheets.RuneCostSheet[10036]; + + RuneHelper.TryEnhancement(48, costRow, new TestRandom(3), 4, out var expected); + RuneHelper.TryEnhancement(48, costRow, new TestRandom(3), 4, out var actual, boost, 150); + + Assert.Equal(expected.ToString(), actual.ToString()); + } + /// /// Resolves for a rune lists. /// diff --git a/Lib9c/Helper/RuneEnhancementCostBoost.cs b/Lib9c/Helper/RuneEnhancementCostBoost.cs index a9e767147fc..77d42651f86 100644 --- a/Lib9c/Helper/RuneEnhancementCostBoost.cs +++ b/Lib9c/Helper/RuneEnhancementCostBoost.cs @@ -4,19 +4,27 @@ namespace Nekoyume.Helper { /// - /// A already checked to apply to one rune's enhancement cost: - /// an enhancement cost row is active and the rune is summonable. Only - /// creates one, so a cost can never be - /// adjusted by handing the raw patched sheet to or - /// and skipping that check. + /// A already checked to apply to the enhancement cost of one + /// rune, : an enhancement cost row was active and the rune was summonable + /// when it was resolved. Only creates + /// one, and and + /// ignore it for any other rune, so a + /// boost only ever discounts the rune it was resolved for — neither handing over the raw + /// patched sheet nor reusing a boost after switching runes can skip the check. /// public sealed class RuneEnhancementCostBoost { - internal RuneEnhancementCostBoost(BoostScheduleSheet boostScheduleSheet) + internal RuneEnhancementCostBoost(BoostScheduleSheet boostScheduleSheet, int runeId) { BoostScheduleSheet = boostScheduleSheet; + RuneId = runeId; } + /// + /// The rune this boost was resolved for, and the only one it adjusts. + /// + public int RuneId { get; } + internal BoostScheduleSheet BoostScheduleSheet { get; } } } diff --git a/Lib9c/Helper/RuneHelper.cs b/Lib9c/Helper/RuneHelper.cs index 030688a5ee2..59c162d34da 100644 --- a/Lib9c/Helper/RuneHelper.cs +++ b/Lib9c/Helper/RuneHelper.cs @@ -44,7 +44,9 @@ public static FungibleAssetValue ToFungibleAssetValue( /// /// What returned for this rune, or null /// for none. Adjusts each try's crystal and rune stone cost; see - /// . + /// . Ignored, not rejected, when it was resolved + /// for a rune other than 's, so a preview holding on to a stale + /// boost shows the undiscounted cost the chain would charge. /// /// /// The block being evaluated, against which rows are matched. @@ -74,7 +76,7 @@ public static bool TryEnhancement( return false; } - cost = ApplyEnhancementCostBoost(cost, targetLevel, boost, blockIndex); + cost = ApplyEnhancementCostBoost(cost, costRow.RuneId, targetLevel, boost, blockIndex); // Cost burns in every try levelUpResult.NcgCost += cost.NcgQuantity; @@ -142,7 +144,7 @@ public static bool TryEnhancement( { if (recipeId == runeId) { - return new RuneEnhancementCostBoost(boostScheduleSheet); + return new RuneEnhancementCostBoost(boostScheduleSheet, runeId); } } } @@ -164,12 +166,16 @@ private static bool IsActiveEnhancementCostRow(BoostScheduleSheet.Row row, long /// . /// /// The sheet's cost for . + /// + /// The rune belongs to. applies only when + /// it was resolved for this rune. + /// /// /// The level the try attempts to reach, matched against target_id. /// /// /// What returned for the rune, or null - /// for none. + /// for none. Ignored when resolved for a rune other than . /// /// The block being evaluated. /// @@ -185,11 +191,12 @@ private static bool IsActiveEnhancementCostRow(BoostScheduleSheet.Row row, long /// public static RuneCostSheet.RuneCostData ApplyEnhancementCostBoost( RuneCostSheet.RuneCostData cost, + int runeId, int targetLevel, RuneEnhancementCostBoost? boost, long blockIndex) { - if (boost is null) + if (boost is null || boost.RuneId != runeId) { return cost; } From 2d70c54fb3f2951a34ab2bb174251079c80af8d8 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Mon, 12 Oct 2026 11:04:27 +0900 Subject: [PATCH 34/41] =?UTF-8?q?refactor:=20=EB=A3=AC=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20=EB=B9=84=EC=9A=A9=20=EB=B6=80=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=9D=84=20named=20argument=20=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runeId 와 targetLevel 이 둘 다 int 라 순서가 바뀌어도 컴파일된다. 내부 호출처를 이름으로 넘겨 실수를 막는다. Co-Authored-By: Claude Opus 5.5 --- Lib9c/Helper/RuneHelper.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Lib9c/Helper/RuneHelper.cs b/Lib9c/Helper/RuneHelper.cs index 59c162d34da..292f160864a 100644 --- a/Lib9c/Helper/RuneHelper.cs +++ b/Lib9c/Helper/RuneHelper.cs @@ -76,7 +76,12 @@ public static bool TryEnhancement( return false; } - cost = ApplyEnhancementCostBoost(cost, costRow.RuneId, targetLevel, boost, blockIndex); + cost = ApplyEnhancementCostBoost( + cost, + runeId: costRow.RuneId, + targetLevel: targetLevel, + boost: boost, + blockIndex: blockIndex); // Cost burns in every try levelUpResult.NcgCost += cost.NcgQuantity; From 54a5805801ae20dec0d620d8e9506b2348cfde46 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Mon, 12 Oct 2026 11:51:45 +0900 Subject: [PATCH 35/41] =?UTF-8?q?ci:=20feature/*=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=EB=8F=84=20dev=20NuGet=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=20=EB=B0=9C=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 232d301007d..6a5efad8f1d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,8 @@ on: - development - main - release/* + # 기능 브랜치도 dev 패키지를 뽑아 하위 저장소(백오피스·헤드리스)에서 머지 전에 범프해 볼 수 있게 + - feature/* tags: - "*.*.*" From 5644833dc2c97e378576671703f98b21fea66fd7 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Mon, 12 Oct 2026 12:26:56 +0900 Subject: [PATCH 36/41] =?UTF-8?q?feat:=20BoostScheduleSheet=20=EB=A0=88?= =?UTF-8?q?=EB=B2=84=EB=B3=84=20=EC=9E=91=EC=84=B1=20=EC=95=88=EB=82=B4(Ta?= =?UTF-8?q?rgets.Infos)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - target 마다 무엇을 바꾸는지, target_id 가 가리키는 것·시트, 보통 쓰는 op, 값 처리(내림·상하한), 적용 범위 밖·선배포 조건, 예시 행을 정적 데이터로 둔다. 액션은 읽지 않는 참고용 - 백오피스 등 도구가 이걸 그대로 보여줘, 레버를 추가·변경할 때 설명도 같은 PR 에서 바뀐다 - Applied 는 Infos 에서 뽑아 레버 목록을 한 곳에서만 관리 - 모든 Targets 상수가 정확히 한 번 설명되고, 예시 행이 검증을 통과하는지 테스트 Co-Authored-By: Claude Opus 5.5 --- .../TableData/BoostScheduleSheetTest.cs | 33 +++ Lib9c/TableData/BoostScheduleSheet.cs | 200 ++++++++++++++++-- 2 files changed, 218 insertions(+), 15 deletions(-) diff --git a/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs b/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs index 3ed88281763..fe67e2cdeb2 100644 --- a/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs +++ b/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs @@ -1,5 +1,6 @@ namespace Lib9c.Tests.TableData { + using System.Linq; using Nekoyume.TableData; using Xunit; @@ -26,6 +27,38 @@ public void ShippedCsvSchedulesNothing() Assert.Empty(sheet); } + [Fact] + public void EveryTargetIsDescribedOnce() + { + var targets = typeof(BoostScheduleSheet.Targets) + .GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) + .Where(f => f.IsLiteral && f.FieldType == typeof(string)) + .Select(f => (string)f.GetRawConstantValue()) + .OrderBy(t => t); + + Assert.Equal( + targets, + BoostScheduleSheet.Targets.Infos.Select(i => i.Target).OrderBy(t => t)); + Assert.Equal( + BoostScheduleSheet.Targets.Infos.Count, + BoostScheduleSheet.Targets.Infos.Select(i => i.Target).Distinct().Count()); + } + + [Fact] + public void EveryExampleIsAValidRowOfItsTarget() + { + foreach (var info in BoostScheduleSheet.Targets.Infos) + { + var row = info.Example.Replace("", "100").Replace("", "200"); + BoostScheduleSheet.ValidateCsv(Header + row + "\n"); + var sheet = Parse(row + "\n"); + Assert.Equal(info.Target, Assert.Single(sheet.Values).Target); + Assert.All( + new[] { info.Adjusts, info.TargetId, info.TargetIdSheet, info.UsualOp, info.ValueRule, info.Notes }, + text => Assert.False(string.IsNullOrWhiteSpace(text))); + } + } + [Fact] public void ParsesRow() { diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index cf88f39cf89..98cf2fb6e54 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -223,29 +223,199 @@ public static class Targets public const string SynthesizeRequiredCount = "SYNTHESIZE_REQUIRED_COUNT"; /// - /// Every target above, for tooling that warns about rows no running code applies. + /// How to write a row for each target above, for tooling that helps people fill the + /// sheet (the backoffice serves this as is). It is reference text only: no action reads + /// it, so changing it never changes what a row does. /// - public static readonly IReadOnlyCollection Applied = new[] + public static readonly IReadOnlyList Infos = new[] { - EquipmentSummonGuarantee, - RuneSummonGuarantee, - CostumeSummonGuarantee, - StageItemReward, - StageFavReward, - AdventureBossFloorReward, - AdventureBossFirstClearReward, - WorldBossBattleReward, - WorldBossKillReward, - WorldBossRankReward, - SummonRuneEnhancementCrystalCost, - SummonRuneEnhancementRuneStoneCost, - SynthesizeRequiredCount, + new TargetInfo( + EquipmentSummonGuarantee, + "Guaranteed count of minimum-grade results in one equipment (aura) summon.", + "EquipmentSummonSheet group id", + "EquipmentSummonSheet", + "ADD", + "Added to (or multiplied with) the group's configured guarantee count, floored. " + + "Never more than the summon count minus one, unless the sheet already configures more.", + "Only adjusts a guarantee the group already has; a group whose guarantee count is 0 stays 0. " + + "Applies only to summon counts where the sheet's guarantee applies.", + "1,EQUIPMENT_SUMMON_GUARANTEE,*,ADD,1,,"), + new TargetInfo( + RuneSummonGuarantee, + "Guaranteed count of minimum-grade results in one rune summon.", + "RuneSummonSheet group id", + "RuneSummonSheet", + "ADD", + "As EQUIPMENT_SUMMON_GUARANTEE.", + "As EQUIPMENT_SUMMON_GUARANTEE.", + "1,RUNE_SUMMON_GUARANTEE,*,ADD,1,,"), + new TargetInfo( + CostumeSummonGuarantee, + "Guaranteed count of minimum-grade results in one costume summon.", + "CostumeSummonSheet group id", + "CostumeSummonSheet", + "ADD", + "As EQUIPMENT_SUMMON_GUARANTEE.", + "As EQUIPMENT_SUMMON_GUARANTEE.", + "1,COSTUME_SUMMON_GUARANTEE,*,ADD,1,,"), + new TargetInfo( + SynthesizeRequiredCount, + "Materials one synthesis consumes (SynthesizeSheet required_count).", + "material grade id (SynthesizeSheet grade_id, e.g. 1~4); applies to every item_sub_type of the grade", + "SynthesizeSheet", + "MUL below 1 (cheaper) or ADD negative", + "Floored, never below 1. A typo such as MUL 0.06 brings every grade down to 1.", + "Patch only after a client that picks materials by the adjusted count is released: " + + "an old client fails every synthesis while the row is active.", + "1,SYNTHESIZE_REQUIRED_COUNT,1~4,MUL,0.5,,"), + new TargetInfo( + SummonRuneEnhancementCrystalCost, + "Crystal cost of one rune enhancement try, for summonable runes only (rune ids in any RuneSummonSheet recipe).", + "rune level the try reaches (current level + 1); 1~50 covers tries from level 0 to 50", + "RuneCostSheet", + "MUL below 1", + "Floored, kept between 1 and the sheet's cost: a row can only discount.", + "Cannot narrow to particular rune ids. NCG cost and success rate are not adjustable.", + "1,SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST,1~50,MUL,0.5,,"), + new TargetInfo( + SummonRuneEnhancementRuneStoneCost, + "Rune stone cost of one rune enhancement try, for summonable runes only.", + "rune level the try reaches, as SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST", + "RuneCostSheet", + "MUL below 1", + "As SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST.", + "As SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST.", + "1,SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST,1~50,MUL,0.5,,"), + new TargetInfo( + WorldBossBattleReward, + "Every amount (runes, crystal, circles) of the reward a raid battle grants.", + "boss id (WorldBossListSheet boss_id, e.g. 900001)", + "WorldBossListSheet", + "MUL above 1", + "Each drawn amount is scaled after the draw and floored, so the draws are unchanged.", + "The block is the one the raid is played in.", + "1,WORLD_BOSS_BATTLE_REWARD,*,MUL,2,,"), + new TargetInfo( + WorldBossKillReward, + "Every amount of each boss kill (level up) reward.", + "boss id (WorldBossListSheet boss_id)", + "WorldBossListSheet", + "MUL above 1", + "As WORLD_BOSS_BATTLE_REWARD.", + "Judged at the block the reward is granted or claimed, not when the boss was killed: " + + "kills left unclaimed before the event are boosted if claimed during it.", + "1,WORLD_BOSS_KILL_REWARD,*,MUL,2,,"), + new TargetInfo( + WorldBossRankReward, + "Every amount of each rank reward ClaimRaidReward grants.", + "boss id (WorldBossListSheet boss_id)", + "WorldBossListSheet", + "MUL above 1", + "As WORLD_BOSS_BATTLE_REWARD.", + "Judged at the claim block, not when the rank was reached: unclaimed ranks are boosted if claimed during the event. " + + "The contribution reward is not adjustable.", + "1,WORLD_BOSS_RANK_REWARD,*,MUL,2,,"), + new TargetInfo( + AdventureBossFloorReward, + "Amount of the reward an adventure boss floor drops on every clear (explore and sweep).", + "floor number (AdventureBossFloorSheet floor, 1 is every boss's first floor), not the row id", + "AdventureBossFloorSheet", + "MUL above 1", + "Scaled after the draw and floored.", + "NCG and season-end claim rewards are not adjustable.", + "1,ADVENTURE_BOSS_FLOOR_REWARD,*,MUL,2,,"), + new TargetInfo( + AdventureBossFirstClearReward, + "Amount of each first-clear reward of an adventure boss floor (once per season).", + "floor number, as ADVENTURE_BOSS_FLOOR_REWARD", + "AdventureBossFloorFirstRewardSheet", + "MUL above 1", + "Scaled and floored.", + "As ADVENTURE_BOSS_FLOOR_REWARD.", + "1,ADVENTURE_BOSS_FIRST_CLEAR_REWARD,*,MUL,2,,"), + new TargetInfo( + StageItemReward, + "Material item rewards of an adventure stage clear (HackAndSlash, sweep).", + "StageSheet stage id (1~300 covers stages 1 to 300)", + "StageSheet", + "MUL 2 or more, or ADD", + "Per item id per clear, after the draw, floored; MUL at most 10. " + + "Any MUL above 1 turns an item drawn once into 2, so MUL 1.01 doubles single drops.", + "Event dungeon stages are not covered. Boosted items count toward quests like any reward.", + "1,STAGE_ITEM_REWARD,1~300,MUL,2,,"), + new TargetInfo( + StageFavReward, + "Fungible asset rewards (crystal, rune stones) of an adventure stage clear.", + "StageSheet stage id", + "StageSheet", + "MUL above 1", + "Each drawn ticker amount is scaled after the draw and floored.", + "Event dungeon stages are not covered.", + "1,STAGE_FAV_REWARD,1~300,MUL,1.5,,"), }; + + /// + /// Every target above, for tooling that warns about rows no running code applies. + /// + public static readonly IReadOnlyCollection Applied = + Infos.Select(info => info.Target).ToArray(); } /// /// How a row's is combined with the value it adjusts. /// + /// + /// How to write a row for one target: reference text for people and tools, never read by + /// an action. See . + /// + public sealed class TargetInfo + { + /// Creates a description. + public TargetInfo( + string target, + string adjusts, + string targetId, + string targetIdSheet, + string usualOp, + string valueRule, + string notes, + string example) + { + Target = target; + Adjusts = adjusts; + TargetId = targetId; + TargetIdSheet = targetIdSheet; + UsualOp = usualOp; + ValueRule = valueRule; + Notes = notes; + Example = example; + } + + /// The target cell. + public string Target { get; } + + /// What a row changes. + public string Adjusts { get; } + + /// What the target_id cell names. + public string TargetId { get; } + + /// The sheet whose ids target_id takes. + public string TargetIdSheet { get; } + + /// The op and value direction an event normally uses. + public string UsualOp { get; } + + /// How the value is applied: rounding and bounds. + public string ValueRule { get; } + + /// What the row does not cover, and what to release first. + public string Notes { get; } + + /// An example row, with the block range left as placeholders. + public string Example { get; } + } + public enum Operation { /// From 9c4ddb6bdb4a2a1a05b9c57361bea7acba3afd5b Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Mon, 12 Oct 2026 12:41:44 +0900 Subject: [PATCH 37/41] =?UTF-8?q?fix:=20=EB=A0=88=EB=B2=84=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94?= =?UTF-8?q?=20=EC=86=8C=ED=99=98=2011/110=20=EB=8F=99=EC=8B=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=C2=B70=EA=B9=8C=EC=A7=80=20=ED=95=98=ED=96=A5,=20?= =?UTF-8?q?=EC=8A=A4=ED=85=8C=EC=9D=B4=EC=A7=80=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=2010=EB=B0=B0,=20=EA=B5=AC=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=EC=98=81=ED=96=A5,=20=EC=9B=94?= =?UTF-8?q?=EB=93=9C=EB=B3=B4=EC=8A=A4=20boss=5Fid=E2=89=A0raid=20id,=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20RowRules,=20TargetIdMeaning=20=EA=B0=9C?= =?UTF-8?q?=EB=AA=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../TableData/BoostScheduleSheetTest.cs | 12 +- Lib9c/TableData/BoostScheduleSheet.cs | 126 +++++++++++------- 2 files changed, 88 insertions(+), 50 deletions(-) diff --git a/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs b/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs index fe67e2cdeb2..eabeeca6721 100644 --- a/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs +++ b/.Lib9c.Tests/TableData/BoostScheduleSheetTest.cs @@ -54,11 +54,21 @@ public void EveryExampleIsAValidRowOfItsTarget() var sheet = Parse(row + "\n"); Assert.Equal(info.Target, Assert.Single(sheet.Values).Target); Assert.All( - new[] { info.Adjusts, info.TargetId, info.TargetIdSheet, info.UsualOp, info.ValueRule, info.Notes }, + new[] { info.Adjusts, info.TargetIdMeaning, info.TargetIdSheet, info.UsualOp, info.ValueRule, info.Notes }, text => Assert.False(string.IsNullOrWhiteSpace(text))); } } + [Fact] + public void ExamplesTogetherFormAValidSheet() + { + // Pasting every example at once must not trip on duplicate ids. + var rows = string.Concat(BoostScheduleSheet.Targets.Infos.Select(i => + i.Example.Replace("", "100").Replace("", "200") + "\n")); + BoostScheduleSheet.ValidateCsv(Header + rows); + Assert.NotEmpty(BoostScheduleSheet.Targets.RowRules); + } + [Fact] public void ParsesRow() { diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index 98cf2fb6e54..036fe460072 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -225,7 +225,8 @@ public static class Targets /// /// How to write a row for each target above, for tooling that helps people fill the /// sheet (the backoffice serves this as is). It is reference text only: no action reads - /// it, so changing it never changes what a row does. + /// it, so changing it never changes what a row does. Rules shared by every row are in + /// . /// public static readonly IReadOnlyList Infos = new[] { @@ -235,10 +236,12 @@ public static class Targets "EquipmentSummonSheet group id", "EquipmentSummonSheet", "ADD", - "Added to (or multiplied with) the group's configured guarantee count, floored. " + - "Never more than the summon count minus one, unless the sheet already configures more.", - "Only adjusts a guarantee the group already has; a group whose guarantee count is 0 stays 0. " + - "Applies only to summon counts where the sheet's guarantee applies.", + "Applied to the group's configured guarantee count, floored. One row adjusts both the 11-summon " + + "(GuaranteeCount11) and the 110-summon (GuaranteeCount110) guarantee: ADD 1 turns 1 into 2 for 11 " + + "but e.g. 10 into 11 for 110. Capped at the summon count minus one unless the sheet already " + + "configures more. MUL below 1 or a negative ADD lowers it, down to 0.", + "Never creates a guarantee: a group configured with 0 stays 0, and single summons have none. " + + "An old client replays the summon without the row and shows results that differ from what is granted: release a client that passes the row first.", "1,EQUIPMENT_SUMMON_GUARANTEE,*,ADD,1,,"), new TargetInfo( RuneSummonGuarantee, @@ -246,103 +249,112 @@ public static class Targets "RuneSummonSheet group id", "RuneSummonSheet", "ADD", - "As EQUIPMENT_SUMMON_GUARANTEE.", - "As EQUIPMENT_SUMMON_GUARANTEE.", - "1,RUNE_SUMMON_GUARANTEE,*,ADD,1,,"), + "As EQUIPMENT_SUMMON_GUARANTEE (11 and 110 summons alike).", + "As EQUIPMENT_SUMMON_GUARANTEE. An old client replays the summon without the row and shows results that differ from what is granted: release a client that passes the row first.", + "2,RUNE_SUMMON_GUARANTEE,*,ADD,1,,"), new TargetInfo( CostumeSummonGuarantee, "Guaranteed count of minimum-grade results in one costume summon.", "CostumeSummonSheet group id", "CostumeSummonSheet", "ADD", - "As EQUIPMENT_SUMMON_GUARANTEE.", - "As EQUIPMENT_SUMMON_GUARANTEE.", - "1,COSTUME_SUMMON_GUARANTEE,*,ADD,1,,"), + "As EQUIPMENT_SUMMON_GUARANTEE (11 and 110 summons alike).", + "As EQUIPMENT_SUMMON_GUARANTEE. An old client replays the summon without the row and shows results that differ from what is granted: release a client that passes the row first.", + "3,COSTUME_SUMMON_GUARANTEE,*,ADD,1,,"), new TargetInfo( SynthesizeRequiredCount, "Materials one synthesis consumes (SynthesizeSheet required_count).", "material grade id (SynthesizeSheet grade_id, e.g. 1~4); applies to every item_sub_type of the grade", "SynthesizeSheet", - "MUL below 1 (cheaper) or ADD negative", + "MUL below 1 (cheaper) or a negative ADD", "Floored, never below 1. A typo such as MUL 0.06 brings every grade down to 1.", - "Patch only after a client that picks materials by the adjusted count is released: " + - "an old client fails every synthesis while the row is active.", - "1,SYNTHESIZE_REQUIRED_COUNT,1~4,MUL,0.5,,"), + "Patch only after a client that picks materials by the adjusted count is released: an old " + + "client fails every synthesis while the row is active (the action requires a multiple of the " + + "adjusted count).", + "4,SYNTHESIZE_REQUIRED_COUNT,1~4,MUL,0.5,,"), new TargetInfo( SummonRuneEnhancementCrystalCost, "Crystal cost of one rune enhancement try, for summonable runes only (rune ids in any RuneSummonSheet recipe).", - "rune level the try reaches (current level + 1); 1~50 covers tries from level 0 to 50", + "the rune level the try reaches (current level + 1): 1~50 covers the tries 0→1 through 49→50", "RuneCostSheet", "MUL below 1", "Floored, kept between 1 and the sheet's cost: a row can only discount.", - "Cannot narrow to particular rune ids. NCG cost and success rate are not adjustable.", - "1,SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST,1~50,MUL,0.5,,"), + "Cannot narrow to particular rune ids. NCG cost and success rate are not adjustable. " + + "An old client shows and checks the sheet's cost (charged correctly, displayed wrong): release a client first.", + "5,SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST,1~50,MUL,0.5,,"), new TargetInfo( SummonRuneEnhancementRuneStoneCost, "Rune stone cost of one rune enhancement try, for summonable runes only.", - "rune level the try reaches, as SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST", + "the rune level the try reaches, as SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST", "RuneCostSheet", "MUL below 1", "As SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST.", "As SUMMON_RUNE_ENHANCEMENT_CRYSTAL_COST.", - "1,SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST,1~50,MUL,0.5,,"), + "6,SUMMON_RUNE_ENHANCEMENT_RUNE_STONE_COST,1~50,MUL,0.5,,"), new TargetInfo( WorldBossBattleReward, "Every amount (runes, crystal, circles) of the reward a raid battle grants.", - "boss id (WorldBossListSheet boss_id, e.g. 900001)", + "boss id (WorldBossListSheet boss_id, e.g. 900001), not the raid id (the row id); a boss id " + + "is reused across seasons, so the row covers every season of that boss within the block range", "WorldBossListSheet", "MUL above 1", "Each drawn amount is scaled after the draw and floored, so the draws are unchanged.", - "The block is the one the raid is played in.", - "1,WORLD_BOSS_BATTLE_REWARD,*,MUL,2,,"), + "The block is the one the raid is played in. An old client shows the unboosted battle reward " + + "(granted correctly).", + "7,WORLD_BOSS_BATTLE_REWARD,*,MUL,2,,"), new TargetInfo( WorldBossKillReward, "Every amount of each boss kill (level up) reward.", - "boss id (WorldBossListSheet boss_id)", + "boss id (WorldBossListSheet boss_id), not the raid id; as WORLD_BOSS_BATTLE_REWARD", "WorldBossListSheet", "MUL above 1", "As WORLD_BOSS_BATTLE_REWARD.", - "Judged at the block the reward is granted or claimed, not when the boss was killed: " + - "kills left unclaimed before the event are boosted if claimed during it.", - "1,WORLD_BOSS_KILL_REWARD,*,MUL,2,,"), + "Judged at the block the reward is granted or claimed, not when the boss was killed: kills " + + "left unclaimed before the event are boosted if claimed during it. An old client shows the " + + "unboosted reward (granted correctly).", + "8,WORLD_BOSS_KILL_REWARD,*,MUL,2,,"), new TargetInfo( WorldBossRankReward, "Every amount of each rank reward ClaimRaidReward grants.", - "boss id (WorldBossListSheet boss_id)", + "boss id (WorldBossListSheet boss_id), not the raid id; as WORLD_BOSS_BATTLE_REWARD", "WorldBossListSheet", "MUL above 1", "As WORLD_BOSS_BATTLE_REWARD.", - "Judged at the claim block, not when the rank was reached: unclaimed ranks are boosted if claimed during the event. " + - "The contribution reward is not adjustable.", - "1,WORLD_BOSS_RANK_REWARD,*,MUL,2,,"), + "Judged at the claim block, not when the rank was reached: unclaimed ranks are boosted if " + + "claimed during the event. The contribution reward is not adjustable. An old client shows the " + + "unboosted reward (granted correctly).", + "9,WORLD_BOSS_RANK_REWARD,*,MUL,2,,"), new TargetInfo( AdventureBossFloorReward, "Amount of the reward an adventure boss floor drops on every clear (explore and sweep).", "floor number (AdventureBossFloorSheet floor, 1 is every boss's first floor), not the row id", "AdventureBossFloorSheet", "MUL above 1", - "Scaled after the draw and floored.", - "NCG and season-end claim rewards are not adjustable.", - "1,ADVENTURE_BOSS_FLOOR_REWARD,*,MUL,2,,"), + "Scaled after the draw, floored, never below 1.", + "NCG and season-end claim rewards are not adjustable. An old client shows the unboosted " + + "reward (granted correctly).", + "10,ADVENTURE_BOSS_FLOOR_REWARD,*,MUL,2,,"), new TargetInfo( AdventureBossFirstClearReward, "Amount of each first-clear reward of an adventure boss floor (once per season).", "floor number, as ADVENTURE_BOSS_FLOOR_REWARD", "AdventureBossFloorFirstRewardSheet", "MUL above 1", - "Scaled and floored.", + "Scaled, floored, never below 1.", "As ADVENTURE_BOSS_FLOOR_REWARD.", - "1,ADVENTURE_BOSS_FIRST_CLEAR_REWARD,*,MUL,2,,"), + "11,ADVENTURE_BOSS_FIRST_CLEAR_REWARD,*,MUL,2,,"), new TargetInfo( StageItemReward, "Material item rewards of an adventure stage clear (HackAndSlash, sweep).", "StageSheet stage id (1~300 covers stages 1 to 300)", "StageSheet", "MUL 2 or more, or ADD", - "Per item id per clear, after the draw, floored; MUL at most 10. " + - "Any MUL above 1 turns an item drawn once into 2, so MUL 1.01 doubles single drops.", - "Event dungeon stages are not covered. Boosted items count toward quests like any reward.", - "1,STAGE_ITEM_REWARD,1~300,MUL,2,,"), + "Per item id per clear, after the draw, floored. The result is capped at 10 times the drawn " + + "count (ADD too); the value itself is not rejected, so MUL 20 grants 10×. Any MUL above 1 " + + "turns an item drawn once into 2, so MUL 1.01 doubles single drops.", + "Event dungeon stages are not covered. Boosted items count toward quests like any reward. " + + "An old client shows the unboosted rewards (granted correctly).", + "12,STAGE_ITEM_REWARD,1~300,MUL,2,,"), new TargetInfo( StageFavReward, "Fungible asset rewards (crystal, rune stones) of an adventure stage clear.", @@ -350,8 +362,24 @@ public static class Targets "StageSheet", "MUL above 1", "Each drawn ticker amount is scaled after the draw and floored.", - "Event dungeon stages are not covered.", - "1,STAGE_FAV_REWARD,1~300,MUL,1.5,,"), + "Event dungeon stages are not covered. An old client shows the unboosted rewards (granted correctly).", + "13,STAGE_FAV_REWARD,1~300,MUL,1.5,,"), + }; + + /// + /// Rules every row follows whatever its target, for the same tooling as + /// . Reference text only. + /// + public static readonly IReadOnlyList RowRules = new[] + { + "Columns: id,target,target_id,op,value,start_block,end_block, optionally followed by \"_\" memo columns (never first).", + "id: a unique positive integer. When rows for the same target and target_id overlap, the lowest id wins; rows never add up.", + "target_id: \"*\" for every id, a single id such as 3, or an inclusive range such as 1~50.", + "op MUL: value greater than 0 (decimals allowed). op ADD: an integer, negative allowed. |value| at most 2147483647.", + "Period: start_block inclusive, end_block exclusive, start_block < end_block. Blocks differ per planet.", + "A patch replaces the whole sheet: keep every row that should stay.", + "Results are floored and never negative; each target adds its own bounds (see its value rule).", + "Patch a row only after every node runs a lib9c that applies its target, or nodes disagree while it is active.", }; /// @@ -361,9 +389,6 @@ public static class Targets Infos.Select(info => info.Target).ToArray(); } - /// - /// How a row's is combined with the value it adjusts. - /// /// /// How to write a row for one target: reference text for people and tools, never read by /// an action. See . @@ -374,7 +399,7 @@ public sealed class TargetInfo public TargetInfo( string target, string adjusts, - string targetId, + string targetIdMeaning, string targetIdSheet, string usualOp, string valueRule, @@ -383,7 +408,7 @@ public TargetInfo( { Target = target; Adjusts = adjusts; - TargetId = targetId; + TargetIdMeaning = targetIdMeaning; TargetIdSheet = targetIdSheet; UsualOp = usualOp; ValueRule = valueRule; @@ -397,8 +422,8 @@ public TargetInfo( /// What a row changes. public string Adjusts { get; } - /// What the target_id cell names. - public string TargetId { get; } + /// What the target_id cell names (a description, not a value). + public string TargetIdMeaning { get; } /// The sheet whose ids target_id takes. public string TargetIdSheet { get; } @@ -416,6 +441,9 @@ public TargetInfo( public string Example { get; } } + /// + /// How a row's is combined with the value it adjusts. + /// public enum Operation { /// From 07c242c9b03579a92a24486f53a8543af66dfe6d Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Mon, 12 Oct 2026 17:56:56 +0900 Subject: [PATCH 38/41] =?UTF-8?q?feat:=20BoostScheduleSheet=20=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=95=ED=99=94=20=EB=A7=9D=EC=B9=98=20exp=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=20(ENHANCEMENT=5FHAMMER=5FEXP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - target_id = 망치 아이템 id(600301~600308) — 등급별 차등을 구간 행으로 표현 - ItemEnhancement.GetHammerExp(sheet, hammerId, boost): 64비트 내림, [원래, 원래×3] 으로 묶어 증가 전용 (공용 Row.Apply(int) 는 int.MaxValue 에서 잘려 초월 망치 5억에 못 쓰므로 Row.Apply(long) 추가) - 부스트된 exp 는 장비에 그대로 저장 — 정상적으로 그만큼 모은 장비와 같아져 다음 강화·재료 가치가 어긋나지 않는다 - 레버 안내(Infos): 구클라는 미리보기 레벨·NCG 비용을 낮게 보여 표시만큼 NCG 를 가진 아바타가 실패 → 클라 선배포 - 시트 미패치 체인은 기존과 동일(TryGetPatchedSheet) Co-Authored-By: Claude Opus 5.5 --- .Lib9c.Tests/Action/ItemEnhancementTest.cs | 111 +++++++++++++++++++++ Lib9c/Action/ItemEnhancement.cs | 51 +++++++++- Lib9c/TableData/BoostScheduleSheet.cs | 54 ++++++++++ 3 files changed, 215 insertions(+), 1 deletion(-) diff --git a/.Lib9c.Tests/Action/ItemEnhancementTest.cs b/.Lib9c.Tests/Action/ItemEnhancementTest.cs index 536dcf86fbf..15aa85a7b29 100644 --- a/.Lib9c.Tests/Action/ItemEnhancementTest.cs +++ b/.Lib9c.Tests/Action/ItemEnhancementTest.cs @@ -387,6 +387,106 @@ public void LoadPlainValue() Assert.Equal(100, newAction.hammers[1]); } + [Theory] + [InlineData(null, 10L)] // no row: the sheet's experience (the fixture's 600301 gives 10) + [InlineData("MUL,2", 20L)] + [InlineData("MUL,1.25", 12L)] // floored + [InlineData("MUL,5", 30L)] // capped at MaxHammerExpBoostFactor (3) times + [InlineData("MUL,0.5", 10L)] // a row only raises it + [InlineData("ADD,5", 15L)] + [InlineData("ADD,1000", 30L)] // capped as well + public void GetHammerExp_AppliesBoostWithinBounds(string opValue, long expected) + { + const int hammerId = 600301; + Assert.Equal(10L, _tableSheets.EnhancementCostSheetV3.GetHammerExp(hammerId)); + var boost = opValue is null ? null : HammerBoost(hammerId, opValue).FindActive( + BoostScheduleSheet.Targets.EnhancementHammerExp, hammerId, 150); + + Assert.Equal( + expected, + ItemEnhancement.GetHammerExp(_tableSheets.EnhancementCostSheetV3, hammerId, boost)); + } + + [Fact] + public void GetHammerExp_IsNotCutAtInt32() + { + // The live top hammer gives 500,000,000. MUL 3 of it is 1,500,000,000, and anything + // above int.MaxValue must survive — the 32-bit Apply would clamp it to int.MaxValue. + const int hammerId = 600308; + var costSheet = new EnhancementCostSheetV3(); + costSheet.Set( + "id,item_sub_type,grade,level,cost,exp,required_block,base_stat_growth_min,base_stat_growth_max," + + "extra_stat_growth_min,extra_stat_growth_max,extra_skill_damage_growth_min,extra_skill_damage_growth_max," + + "extra_skill_chance_growth_min,extra_skill_chance_growth_max\n" + + "600308,EquipmentMaterial,8,0,0,1000000000,0,0,0,0,0,0,0,0,0\n"); + Assert.Equal(1_000_000_000L, costSheet.GetHammerExp(hammerId)); + var boost = HammerBoost(hammerId, "MUL,3").FindActive( + BoostScheduleSheet.Targets.EnhancementHammerExp, hammerId, 150); + + Assert.Equal(3_000_000_000L, ItemEnhancement.GetHammerExp(costSheet, hammerId, boost)); + Assert.Equal(6_000_000_000L, boost.Apply(2_000_000_000L)); + } + + [Theory] + [InlineData(150, true)] // inside [100, 200) + [InlineData(250, false)] // after the schedule ends + public void Execute_AppliesScheduledHammerExpBoost(long blockIndex, bool scheduled) + { + var row = _tableSheets.EquipmentItemSheet.Values.First(r => r.Id == 10110000); + var equipment = (Equipment)ItemFactory.CreateItemUsable(row, default, 0, 0); + equipment.Exp = (long)row.Exp!; + var startExp = equipment.Exp; + var hammerId = ItemEnhancement.HammerIds.First(); + _avatarState.inventory.AddItem(equipment, 1); + _avatarState.inventory.AddItem( + ItemFactory.CreateMaterial(_tableSheets.MaterialItemSheet[hammerId]), + 3); + for (var i = 0; i < 100; i++) + { + _avatarState.Update(new CombinationMail( + new CombinationConsumable5.ResultModel + { + id = default, + materials = new Dictionary(), + itemUsable = equipment, + }, + i, + default, + 0)); + } + + _avatarState.worldInformation.ClearStage( + 1, 1, 1, _tableSheets.WorldSheet, _tableSheets.WorldUnlockSheet); + var state = _initialState + .SetLegacyState( + Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)), + HammerBoostCsv(hammerId, "MUL,2").Serialize()) + .SetAvatarState(_avatarAddress, _avatarState); + + var nextState = new ItemEnhancement + { + itemId = default, + materialIds = new List(), + avatarAddress = _avatarAddress, + slotIndex = 0, + hammers = new Dictionary { [hammerId] = 3 }, + }.Execute(new ActionContext + { + PreviousState = state, + Signer = _agentAddress, + BlockIndex = blockIndex, + RandomSeed = 0, + }); + + var slotResult = (ItemEnhancement13.ResultModel)nextState + .GetAllCombinationSlotState(_avatarAddress).GetSlot(0).Result; + var hammerExp = _tableSheets.EnhancementCostSheetV3.GetHammerExp(hammerId) * 3; + // The boosted experience is what the equipment keeps. + Assert.Equal( + startExp + (scheduled ? hammerExp * 2 : hammerExp), + ((Equipment)slotResult.itemUsable).Exp); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -585,5 +685,16 @@ public void Execute_Throw_InvalidItemTypeException(bool withMaterials) })); } } + + private static string HammerBoostCsv(int hammerId, string opValue) => + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.EnhancementHammerExp},{hammerId},{opValue},100,200\n"; + + private static BoostScheduleSheet HammerBoost(int hammerId, string opValue) + { + var sheet = new BoostScheduleSheet(); + sheet.Set(HammerBoostCsv(hammerId, opValue)); + return sheet; + } } } diff --git a/Lib9c/Action/ItemEnhancement.cs b/Lib9c/Action/ItemEnhancement.cs index bf0e6f2cb39..e9831ba4eaf 100644 --- a/Lib9c/Action/ItemEnhancement.cs +++ b/Lib9c/Action/ItemEnhancement.cs @@ -40,6 +40,13 @@ public class ItemEnhancement : GameAction, IItemEnhancementV5 ItemSubType.Grimoire, }; + /// + /// Upper bound of a row: the + /// adjusted hammer experience never exceeds this many times the configured one, so a + /// mistyped multiplier cannot hand out the top hammer's 500,000,000 experience tenfold. + /// + public const long MaxHammerExpBoostFactor = 3; + public static readonly IReadOnlyCollection HammerIds = new List { 600301, @@ -112,6 +119,39 @@ protected override void LoadPlainValueInternal(IImmutableDictionary + /// Experience one hammer gives, adjusted by a + /// row. + /// + /// The sheet configuring each hammer's experience. + /// The hammer item id. + /// + /// The row active for at the block, or null for none. + /// A client previewing an enhancement must pass the row for the block it expects, or it + /// shows less experience, a lower level and a lower NCG cost than the action produces. + /// + /// + /// The configured experience adjusted by the row, rounded down and kept between the + /// configured experience and times it. + /// + public static long GetHammerExp( + EnhancementCostSheetV3 enhancementCostSheet, + int hammerId, + BoostScheduleSheet.Row hammerExpBoost) + { + var exp = enhancementCostSheet.GetHammerExp(hammerId); + if (hammerExpBoost is null || exp <= 0) + { + return exp; + } + + var boosted = hammerExpBoost.Apply(exp); + var max = exp > long.MaxValue / MaxHammerExpBoostFactor + ? long.MaxValue + : exp * MaxHammerExpBoostFactor; + return Math.Min(Math.Max(boosted, exp), max); + } + public override IWorld Execute(IActionContext context) { GasTracer.UseGas(1); @@ -328,12 +368,21 @@ public override IWorld Execute(IActionContext context) (total, m) => total + m.GetRealExp(equipmentItemSheet, enhancementCostSheet)); // Calculate hammer exp + // Absent until the sheet is patched onto this chain, in which case each hammer gives + // exactly what EnhancementCostSheetV3 configures, as before the sheet existed. + states.TryGetPatchedSheet(out var boostScheduleSheet); var hammerExp = 0L; foreach (var (hammerId, hammerCount) in hammers.OrderBy(kv => kv.Key)) { if (avatarState.inventory.RemoveMaterial(hammerId, context.BlockIndex, hammerCount)) { - var exp = enhancementCostSheet.GetHammerExp(hammerId); + var exp = GetHammerExp( + enhancementCostSheet, + hammerId, + boostScheduleSheet?.FindActive( + BoostScheduleSheet.Targets.EnhancementHammerExp, + hammerId, + context.BlockIndex)); hammerExp += exp * hammerCount; } else diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index 036fe460072..e4207fbb0a7 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -137,6 +137,27 @@ public static class Targets /// public const string StageFavReward = "STAGE_FAV_REWARD"; + /// + /// Experience one enhancement hammer gives an equipment in ItemEnhancement + /// (EnhancementCostSheetV3 hammer rows). target_id is the hammer item id + /// (600301~600308), so grades can get different multipliers. + /// + /// The adjusted experience is stored on the equipment like any other experience: the + /// equipment ends up exactly as if it had gathered that much normally, so it neither + /// owes the boost back on its next enhancement nor is worth less as a material. The + /// result is kept between the configured experience and + /// ItemEnhancement.MaxHammerExpBoostFactor times it — a row can only raise it. + /// See ItemEnhancement.GetHammerExp. + /// + /// + /// Patch a row only after a client that previews with the adjusted experience is + /// released: an old client shows a lower target level and a lower NCG cost than the + /// action charges (cost follows the level reached), so an avatar holding only the + /// shown NCG fails, and it lets players add more hammers than the max level needs. + /// + /// + public const string EnhancementHammerExp = "ENHANCEMENT_HAMMER_EXP"; + /// /// Amount of the reward an adventure boss floor drops on every clear — the one entry /// drawn from AdventureBossFloorSheet rewards per floor, by both @@ -364,6 +385,18 @@ public static class Targets "Each drawn ticker amount is scaled after the draw and floored.", "Event dungeon stages are not covered. An old client shows the unboosted rewards (granted correctly).", "13,STAGE_FAV_REWARD,1~300,MUL,1.5,,"), + new TargetInfo( + EnhancementHammerExp, + "Experience one enhancement hammer gives an equipment (ItemEnhancement).", + "hammer item id (600301~600308; e.g. 600301~600304 for normal to unique)", + "EnhancementCostSheetV3", + "MUL above 1", + "Floored, computed in 64 bits, kept between the configured experience and 3 times it: " + + "a row can only raise it. The boosted experience stays on the equipment like any experience.", + "Aura and grimoire cannot use hammers. An old client previews the unboosted level and NCG cost; " + + "the action charges the cost of the level actually reached, so an avatar holding only the shown " + + "NCG fails: release a client that previews with the row first.", + "14,ENHANCEMENT_HAMMER_EXP,600301~600304,MUL,2,,"), }; /// @@ -575,6 +608,27 @@ public int Apply(int baseValue) return result >= int.MaxValue ? int.MaxValue : (int)result; } + /// + /// for 64-bit values: same rounding (down) and lower bound + /// (0), clamped to instead of + /// so that large bases such as hammer experience are not cut. + /// + /// The value before adjustment. + /// The adjusted value. + public long Apply(long baseValue) + { + var result = Op == Operation.Add + ? baseValue + Value + : baseValue * Value; + result = decimal.Floor(result); + if (result <= 0m) + { + return 0; + } + + return result >= long.MaxValue ? long.MaxValue : (long)result; + } + private static bool TryParseRest( IReadOnlyList fields, out int? targetIdBegin, From 35f6896a30305cdcef7e6e718fe15c97085db403 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Mon, 12 Oct 2026 18:02:54 +0900 Subject: [PATCH 39/41] =?UTF-8?q?test:=20=EB=A7=9D=EC=B9=98=20=EB=B6=80?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EB=8F=84=EB=8B=AC=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=C2=B7NCG=20=EB=B9=84=EC=9A=A9=EC=9D=84=20=EB=B0=94?= =?UTF-8?q?=EA=BE=B8=EA=B3=A0=20=ED=91=9C=EC=8B=9C=20=EB=B9=84=EC=9A=A9?= =?UTF-8?q?=EB=A7=8C=20=EA=B0=80=EC=A7=84=20=EC=95=84=EB=B0=94=ED=83=80?= =?UTF-8?q?=EB=8A=94=20=EC=8B=A4=ED=8C=A8=ED=95=A8=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95,=20long=20=EC=83=81=ED=95=9C=20=EB=B6=84=EA=B8=B0,=20?= =?UTF-8?q?MaxAbsoluteValue=20=EA=B7=BC=EA=B1=B0=C2=B7cref=20=EB=AA=85?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .Lib9c.Tests/Action/ItemEnhancementTest.cs | 135 ++++++++++++++------- Lib9c/Battle/StageSimulator.cs | 4 +- Lib9c/Helper/WorldBossHelper.cs | 2 +- Lib9c/TableData/BoostScheduleSheet.cs | 7 +- 4 files changed, 96 insertions(+), 52 deletions(-) diff --git a/.Lib9c.Tests/Action/ItemEnhancementTest.cs b/.Lib9c.Tests/Action/ItemEnhancementTest.cs index 15aa85a7b29..5417a7eca09 100644 --- a/.Lib9c.Tests/Action/ItemEnhancementTest.cs +++ b/.Lib9c.Tests/Action/ItemEnhancementTest.cs @@ -425,6 +425,16 @@ public void GetHammerExp_IsNotCutAtInt32() Assert.Equal(3_000_000_000L, ItemEnhancement.GetHammerExp(costSheet, hammerId, boost)); Assert.Equal(6_000_000_000L, boost.Apply(2_000_000_000L)); + + // A configured experience above long.MaxValue / 3 keeps long.MaxValue as the cap + // instead of overflowing it. + var hugeSheet = new EnhancementCostSheetV3(); + hugeSheet.Set( + "id,item_sub_type,grade,level,cost,exp,required_block,base_stat_growth_min,base_stat_growth_max," + + "extra_stat_growth_min,extra_stat_growth_max,extra_skill_damage_growth_min,extra_skill_damage_growth_max," + + "extra_skill_chance_growth_min,extra_skill_chance_growth_max\n" + + $"600308,EquipmentMaterial,8,0,0,{long.MaxValue / 2},0,0,0,0,0,0,0,0,0\n"); + Assert.Equal(long.MaxValue, ItemEnhancement.GetHammerExp(hugeSheet, hammerId, boost)); } [Theory] @@ -432,59 +442,40 @@ public void GetHammerExp_IsNotCutAtInt32() [InlineData(250, false)] // after the schedule ends public void Execute_AppliesScheduledHammerExpBoost(long blockIndex, bool scheduled) { - var row = _tableSheets.EquipmentItemSheet.Values.First(r => r.Id == 10110000); - var equipment = (Equipment)ItemFactory.CreateItemUsable(row, default, 0, 0); - equipment.Exp = (long)row.Exp!; - var startExp = equipment.Exp; - var hammerId = ItemEnhancement.HammerIds.First(); - _avatarState.inventory.AddItem(equipment, 1); - _avatarState.inventory.AddItem( - ItemFactory.CreateMaterial(_tableSheets.MaterialItemSheet[hammerId]), - 3); - for (var i = 0; i < 100; i++) - { - _avatarState.Update(new CombinationMail( - new CombinationConsumable5.ResultModel - { - id = default, - materials = new Dictionary(), - itemUsable = equipment, - }, - i, - default, - 0)); - } - - _avatarState.worldInformation.ClearStage( - 1, 1, 1, _tableSheets.WorldSheet, _tableSheets.WorldUnlockSheet); - var state = _initialState - .SetLegacyState( - Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)), - HammerBoostCsv(hammerId, "MUL,2").Serialize()) - .SetAvatarState(_avatarAddress, _avatarState); + // Weapon grade 1 starts at 5 exp; 16 hammers give 160 (level 4, cost 20) or, doubled, + // 320 (level 5, cost 40): the boost raises the level reached and the NCG it costs. + var (state, startExp, hammerId) = PrepareHammerEnhancement(16); + var hammerExp = _tableSheets.EnhancementCostSheetV3.GetHammerExp(hammerId) * 16; - var nextState = new ItemEnhancement - { - itemId = default, - materialIds = new List(), - avatarAddress = _avatarAddress, - slotIndex = 0, - hammers = new Dictionary { [hammerId] = 3 }, - }.Execute(new ActionContext - { - PreviousState = state, - Signer = _agentAddress, - BlockIndex = blockIndex, - RandomSeed = 0, - }); + var nextState = ExecuteHammerEnhancement(state, hammerId, 16, blockIndex); var slotResult = (ItemEnhancement13.ResultModel)nextState .GetAllCombinationSlotState(_avatarAddress).GetSlot(0).Result; - var hammerExp = _tableSheets.EnhancementCostSheetV3.GetHammerExp(hammerId) * 3; + var resultEquipment = (Equipment)slotResult.itemUsable; // The boosted experience is what the equipment keeps. + Assert.Equal(startExp + (scheduled ? hammerExp * 2 : hammerExp), resultEquipment.Exp); + Assert.Equal(scheduled ? 5 : 4, resultEquipment.level); + Assert.Equal(scheduled ? 40 : 20, slotResult.gold); Assert.Equal( - startExp + (scheduled ? hammerExp * 2 : hammerExp), - ((Equipment)slotResult.itemUsable).Exp); + (3_000_000 - slotResult.gold) * _currency, + nextState.GetBalance(_agentAddress, _currency)); + } + + [Fact] + public void Execute_FailsWhenOnlyTheUnboostedCostIsHeld() + { + // What an old client shows (level 4, 20 NCG) is not what the action charges while the + // row is active (level 5, 40 NCG): an avatar holding just the shown cost fails. + var (state, _, hammerId) = PrepareHammerEnhancement(16); + state = state.TransferAsset( + new ActionContext(), + _agentAddress, + Addresses.GoldCurrency, + _currency * (3_000_000 - 20)); + + ExecuteHammerEnhancement(state, hammerId, 16, 250); // unboosted: exactly affordable + Assert.Throws( + () => ExecuteHammerEnhancement(state, hammerId, 16, 150)); } [Theory] @@ -696,5 +687,55 @@ private static BoostScheduleSheet HammerBoost(int hammerId, string opValue) sheet.Set(HammerBoostCsv(hammerId, opValue)); return sheet; } + + private (IWorld State, long StartExp, int HammerId) PrepareHammerEnhancement(int hammerCount) + { + var row = _tableSheets.EquipmentItemSheet.Values.First(r => r.Id == 10110000); + var equipment = (Equipment)ItemFactory.CreateItemUsable(row, default, 0, 0); + equipment.Exp = (long)row.Exp!; + var hammerId = ItemEnhancement.HammerIds.First(); + _avatarState.inventory.AddItem(equipment, 1); + _avatarState.inventory.AddItem( + ItemFactory.CreateMaterial(_tableSheets.MaterialItemSheet[hammerId]), + hammerCount); + for (var i = 0; i < 100; i++) + { + _avatarState.Update(new CombinationMail( + new CombinationConsumable5.ResultModel + { + id = default, + materials = new Dictionary(), + itemUsable = equipment, + }, + i, + default, + 0)); + } + + _avatarState.worldInformation.ClearStage( + 1, 1, 1, _tableSheets.WorldSheet, _tableSheets.WorldUnlockSheet); + var state = _initialState + .SetLegacyState( + Addresses.TableSheet.Derive(nameof(BoostScheduleSheet)), + HammerBoostCsv(hammerId, "MUL,2").Serialize()) + .SetAvatarState(_avatarAddress, _avatarState); + return (state, equipment.Exp, hammerId); + } + + private IWorld ExecuteHammerEnhancement(IWorld state, int hammerId, int hammerCount, long blockIndex) => + new ItemEnhancement + { + itemId = default, + materialIds = new List(), + avatarAddress = _avatarAddress, + slotIndex = 0, + hammers = new Dictionary { [hammerId] = hammerCount }, + }.Execute(new ActionContext + { + PreviousState = state, + Signer = _agentAddress, + BlockIndex = blockIndex, + RandomSeed = 0, + }); } } diff --git a/Lib9c/Battle/StageSimulator.cs b/Lib9c/Battle/StageSimulator.cs index 617332ad217..aec5220c767 100644 --- a/Lib9c/Battle/StageSimulator.cs +++ b/Lib9c/Battle/StageSimulator.cs @@ -220,7 +220,7 @@ public StageSimulator(IRandom random, /// /// itself when is /// null; otherwise a new list with each positive amount replaced by - /// of it, in the same order. A ticker whose + /// of it, in the same order. A ticker whose /// adjusted amount is 0 is left out so that nothing mints a zero amount. /// /// @@ -358,7 +358,7 @@ public static List SplitItemRewardBoost( /// The row to apply. /// How many times the item was drawn in the clear. /// - /// (), except that a + /// (), except that a /// above 1 turns an item drawn once into /// at least 2, so that a fractional multiplier still rewards the single drops most stages /// make. A count of 2 or more keeps the plain floor (MUL 1.2 on 2 stays 2). Capped diff --git a/Lib9c/Helper/WorldBossHelper.cs b/Lib9c/Helper/WorldBossHelper.cs index 58703915a10..4ee8eed8246 100644 --- a/Lib9c/Helper/WorldBossHelper.cs +++ b/Lib9c/Helper/WorldBossHelper.cs @@ -153,7 +153,7 @@ public static (List assets, DictionaryThe amount the reward row and the draws grant. /// /// unchanged when there is no row or nothing is granted; - /// otherwise of it. A result of 0 means the + /// otherwise of it. A result of 0 means the /// amount is not granted at all. /// /// diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index e4207fbb0a7..1932d0ad677 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -56,8 +56,11 @@ public class BoostScheduleSheet : Sheet public const char TargetIdRangeSeparator = '~'; /// - /// Upper bound of |value|. Keeps every inside - /// range so that applying a row can never throw. + /// Upper bound of |value|. Keeps every and + /// inside range so that applying a row + /// can never throw: the worst case, × int.MaxValue + /// ≈ 2e28, stays under ≈ 7.9e28. Raising this bound must keep + /// that true, or a row would make every action it applies to fail. /// public const decimal MaxAbsoluteValue = int.MaxValue; From 62f95e63afec809c40275ea43decf0df1cf33f50 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Mon, 12 Oct 2026 19:43:59 +0900 Subject: [PATCH 40/41] chore: bump to 1.39.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index b75b3ba51d2..9d07182bcdb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ 5.5.2 - 1.38.0 + 1.39.0 From 062ca5e493563891bee0aaadcbad7cec065f9377 Mon Sep 17 00:00:00 2001 From: Yang Chun Ung Date: Tue, 13 Oct 2026 17:15:28 +0900 Subject: [PATCH 41/41] =?UTF-8?q?fix(boost):=20=EC=9B=94=EB=93=9C=EB=B3=B4?= =?UTF-8?q?=EC=8A=A4=C2=B7=EC=96=B4=EB=93=9C=EB=B2=A4=EC=B2=98=EB=B3=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=B3=B4=EC=83=81=20=EB=B6=80=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=205=EB=B0=B0=20=EC=83=81=ED=95=9C=20(#3321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(boost): cap world boss and adventure boss reward boosts at 5x A BoostScheduleSheet row for WORLD_BOSS_BATTLE_REWARD, WORLD_BOSS_KILL_REWARD, WORLD_BOSS_RANK_REWARD, ADVENTURE_BOSS_FLOOR_REWARD or ADVENTURE_BOSS_FIRST_CLEAR_REWARD had no upper bound. A mistyped value (e.g. a block index pasted into the value cell) made ClaimRaidReward add several int.MaxValue circle counts to one inventory slot, overflowing it to a negative count. Each boosted amount is now capped at 5 times the drawn amount, ADD included, computed in 64 bits (WorldBossHelper.MaxWorldBossRewardBoostFactor, AdventureBossHelper.MaxAdventureBossRewardBoostFactor). Lower bounds are unchanged (world boss 0, adventure boss 1) and an amount of 0 is still never boosted. Also make SummonHelperTest.GetSummonRecipeIdsWithGradeGuarantee_AppliesBoost fail when the boost is dropped: assert the last 7 summons are all grade 4+ and that the unboosted run with the same seed is not. Co-Authored-By: Claude Opus 5.5 * docs: document constants next to the boost reward caps Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../AdventureBoss/ExploreAdventureBossTest.cs | 37 ++++++++++++++++++- .../AdventureBoss/SweepAdventureBossTest.cs | 27 +++++++++++++- .Lib9c.Tests/Action/ClaimRaidRewardTest.cs | 20 ++++++++++ .../Action/ClaimWorldBossKillRewardTest.cs | 14 +++++++ .Lib9c.Tests/Action/RaidTest.cs | 22 +++++++++++ .Lib9c.Tests/Action/WorldBossHelperTest.cs | 19 ++++++++++ .../Helper/AdventureBossHelperTest.cs | 12 ++++++ .Lib9c.Tests/Helper/SummonHelperTest.cs | 22 ++++++++--- Lib9c/Helper/AdventureBossHelper.cs | 25 +++++++++++-- Lib9c/Helper/WorldBossHelper.cs | 37 +++++++++++++++++-- Lib9c/TableData/BoostScheduleSheet.cs | 25 +++++++++++-- 11 files changed, 239 insertions(+), 21 deletions(-) diff --git a/.Lib9c.Tests/Action/AdventureBoss/ExploreAdventureBossTest.cs b/.Lib9c.Tests/Action/AdventureBoss/ExploreAdventureBossTest.cs index 1589e8f45e3..117b12f8466 100644 --- a/.Lib9c.Tests/Action/AdventureBoss/ExploreAdventureBossTest.cs +++ b/.Lib9c.Tests/Action/AdventureBoss/ExploreAdventureBossTest.cs @@ -368,6 +368,29 @@ public void Execute_AppliesScheduledRewardBoost() Assert.Equal(Combine(baseRewards, firstClear, 1), firstBoosted); } + // Each target grants at most AdventureBossHelper.MaxAdventureBossRewardBoostFactor (5) + // times its own part, however large the value. + [Theory] + [InlineData("2", 2)] + [InlineData("100", 5)] + [InlineData("2147483647", 5)] + public void Execute_CapsScheduledRewardBoost(string value, int multiplier) + { + var (baseRewards, baseScore, adventureBossId) = ExploreWithBoostSchedule(null); + var firstClear = FirstClearRewards(adventureBossId, 1, 5); + var floorRewards = Combine(baseRewards, firstClear, -1); + + var (floorBoosted, floorBoostedScore, _) = ExploreWithBoostSchedule( + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFloorReward, "1~5", 0, 10, value)); + var (firstBoosted, firstBoostedScore, _) = ExploreWithBoostSchedule( + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFirstClearReward, "1~5", 0, 10, value)); + + Assert.Equal(baseScore, floorBoostedScore); + Assert.Equal(baseScore, firstBoostedScore); + Assert.Equal(Combine(baseRewards, Scale(floorRewards, multiplier - 1), 1), floorBoosted); + Assert.Equal(Combine(baseRewards, Scale(firstClear, multiplier - 1), 1), firstBoosted); + } + [Fact] public void Execute_MatchesFloorRewardBoostByFloorNumber() { @@ -437,9 +460,19 @@ public void Execute_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet Assert.Equal(neverPatched, emptySheet); } - private static string BoostCsv(string target, string targetId, long start, long end) => + private static string BoostCsv( + string target, + string targetId, + long start, + long end, + string value = "2") => "id,target,target_id,op,value,start_block,end_block\n" + - $"1,{target},{targetId},MUL,2,{start},{end}\n"; + $"1,{target},{targetId},MUL,{value},{start},{end}\n"; + + private static Dictionary Scale( + Dictionary rewards, + int factor) => + rewards.ToDictionary(pair => pair.Key, pair => pair.Value * factor); // Sums or subtracts per key, leaving out keys that end at zero. private static Dictionary Combine( diff --git a/.Lib9c.Tests/Action/AdventureBoss/SweepAdventureBossTest.cs b/.Lib9c.Tests/Action/AdventureBoss/SweepAdventureBossTest.cs index 0eedc73bb73..8ded2f790b4 100644 --- a/.Lib9c.Tests/Action/AdventureBoss/SweepAdventureBossTest.cs +++ b/.Lib9c.Tests/Action/AdventureBoss/SweepAdventureBossTest.cs @@ -279,6 +279,24 @@ public void Execute_AppliesScheduledFloorRewardBoost() boosted); } + // At most AdventureBossHelper.MaxAdventureBossRewardBoostFactor (5) times each amount. + [Theory] + [InlineData("2", 2)] + [InlineData("100", 5)] + [InlineData("2147483647", 5)] + public void Execute_CapsScheduledFloorRewardBoost(string value, int multiplier) + { + var (baseRewards, baseScore) = SweepWithBoostSchedule(null); + var (boosted, boostedScore) = SweepWithBoostSchedule( + BoostCsv(BoostScheduleSheet.Targets.AdventureBossFloorReward, "*", 0, 10, value)); + + Assert.Contains(baseRewards, pair => pair.Value > 0); + Assert.Equal(baseScore, boostedScore); + Assert.Equal( + baseRewards.ToDictionary(pair => pair.Key, pair => pair.Value * multiplier), + boosted); + } + [Fact] public void Execute_MatchesFloorRewardBoostByFloorNumber() { @@ -352,9 +370,14 @@ public void Execute_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet Assert.Equal(neverPatched, emptySheet); } - private static string BoostCsv(string target, string targetId, long start, long end) => + private static string BoostCsv( + string target, + string targetId, + long start, + long end, + string value = "2") => "id,target,target_id,op,value,start_block,end_block\n" + - $"1,{target},{targetId},MUL,2,{start},{end}\n"; + $"1,{target},{targetId},MUL,{value},{start},{end}\n"; // Sums or subtracts per key, leaving out keys that end at zero. private static Dictionary Combine( diff --git a/.Lib9c.Tests/Action/ClaimRaidRewardTest.cs b/.Lib9c.Tests/Action/ClaimRaidRewardTest.cs index 854112e7dc0..2c119a68d40 100644 --- a/.Lib9c.Tests/Action/ClaimRaidRewardTest.cs +++ b/.Lib9c.Tests/Action/ClaimRaidRewardTest.cs @@ -170,6 +170,26 @@ public void Execute_AppliesScheduledRankRewardBoost( AssertRankReward(csv.Serialize(), expectedMultiplier); } + // At most WorldBossHelper.MaxWorldBossRewardBoostFactor (5) times each amount. 19930424 is a + // block index pasted into the value cell: uncapped, the circles of ranks 1~5 (two of them + // clamped to int.MaxValue) add up past int.MaxValue in one inventory slot and overflow it + // to a negative count. + [Theory] + [InlineData("2", 2)] + [InlineData("100", 5)] + [InlineData("19930424", 5)] + [InlineData("2147483647", 5)] + public void Execute_CapsScheduledRankRewardBoost(string value, int expectedMultiplier) + { + var bossRow = _tableSheets.WorldBossListSheet.OrderedList.First(); + var csv = + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.WorldBossRankReward},{bossRow.BossId},MUL,{value}," + + $"0,{long.MaxValue}\n"; + + AssertRankReward(csv.Serialize(), expectedMultiplier); + } + [Fact] public void Execute_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet() { diff --git a/.Lib9c.Tests/Action/ClaimWorldBossKillRewardTest.cs b/.Lib9c.Tests/Action/ClaimWorldBossKillRewardTest.cs index 4515b5bd8a6..28c848a3aac 100644 --- a/.Lib9c.Tests/Action/ClaimWorldBossKillRewardTest.cs +++ b/.Lib9c.Tests/Action/ClaimWorldBossKillRewardTest.cs @@ -162,6 +162,20 @@ public void Execute_AppliesScheduledKillRewardBoost(long blockIndex, int multipl AssertKillReward(csv.Serialize(), blockIndex, multiplier); } + // At most WorldBossHelper.MaxWorldBossRewardBoostFactor (5) times each amount. + [Theory] + [InlineData("2", 2)] + [InlineData("100", 5)] + [InlineData("2147483647", 5)] + public void Execute_CapsScheduledKillRewardBoost(string value, int multiplier) + { + var csv = + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.WorldBossKillReward},900001,MUL,{value},100,200\n"; + + AssertKillReward(csv.Serialize(), 150L, multiplier); + } + [Fact] public void Execute_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet() { diff --git a/.Lib9c.Tests/Action/RaidTest.cs b/.Lib9c.Tests/Action/RaidTest.cs index 21863fff24c..ea703b730bb 100644 --- a/.Lib9c.Tests/Action/RaidTest.cs +++ b/.Lib9c.Tests/Action/RaidTest.cs @@ -505,6 +505,28 @@ public void Execute_With_Reward_AppliesScheduledBoost( ExecuteWithReward(csv.Serialize(), battleMultiplier, killMultiplier); } + // Each lever grants at most WorldBossHelper.MaxWorldBossRewardBoostFactor (5) times what was + // drawn, however large the value: below the cap a row applies as written. + [Theory] + [InlineData("2", "2", 2, 2)] + [InlineData("100", "100", 5, 5)] + [InlineData("2", "100", 2, 5)] + [InlineData("2147483647", "2147483647", 5, 5)] + public void Execute_With_Reward_CapsScheduledBoost( + string battleValue, + string killValue, + int battleMultiplier, + int killMultiplier) + { + var bossRow = _tableSheets.WorldBossListSheet.First().Value; + var csv = + "id,target,target_id,op,value,start_block,end_block\n" + + $"1,{BoostScheduleSheet.Targets.WorldBossBattleReward},{bossRow.BossId},MUL,{battleValue},0,{long.MaxValue}\n" + + $"2,{BoostScheduleSheet.Targets.WorldBossKillReward},{bossRow.BossId},MUL,{killValue},0,{long.MaxValue}\n"; + + ExecuteWithReward(csv.Serialize(), battleMultiplier, killMultiplier); + } + [Fact] public void Execute_With_Reward_IsUnchangedWhenTheChainWasNeverPatchedWithBoostScheduleSheet() { diff --git a/.Lib9c.Tests/Action/WorldBossHelperTest.cs b/.Lib9c.Tests/Action/WorldBossHelperTest.cs index 13a17ce46d7..ac0bc82785e 100644 --- a/.Lib9c.Tests/Action/WorldBossHelperTest.cs +++ b/.Lib9c.Tests/Action/WorldBossHelperTest.cs @@ -221,11 +221,30 @@ public void ApplyRewardBoost_WithoutBoost_IsUnchanged(int amount) [InlineData("MUL", "1.5", 3, 4)] [InlineData("ADD", "5", 7, 12)] [InlineData("ADD", "-10", 7, 0)] + // Capped at MaxWorldBossRewardBoostFactor (5) times the drawn amount, ADD included. + [InlineData("MUL", "5", 7, 35)] + [InlineData("MUL", "100", 7, 35)] + [InlineData("ADD", "100", 7, 35)] + [InlineData("ADD", "10", 1, 5)] public void ApplyRewardBoost(string op, string value, int amount, int expected) { Assert.Equal(expected, WorldBossHelper.ApplyRewardBoost(CreateBoostRow(op, value), amount)); } + // Amount × 5 exceeds int.MaxValue from ~429M up; the result must stay positive and never + // exceed int.MaxValue, nor 5 times the amount below that. + [Theory] + [InlineData("MUL", "2147483647", int.MaxValue, int.MaxValue)] + [InlineData("ADD", "2147483647", int.MaxValue, int.MaxValue)] + [InlineData("MUL", "2147483647", 1_000_000_000, int.MaxValue)] + [InlineData("MUL", "2147483647", 429_496_729, 2_147_483_645)] + [InlineData("MUL", "19930424", 1000, 5000)] + [InlineData("MUL", "2147483647", 1, 5)] + public void ApplyRewardBoost_DoesNotOverflow(string op, string value, int amount, int expected) + { + Assert.Equal(expected, WorldBossHelper.ApplyRewardBoost(CreateBoostRow(op, value), amount)); + } + [Theory] [InlineData(1000, 250, "25")] [InlineData(1000000, 1, "0.0001")] diff --git a/.Lib9c.Tests/Helper/AdventureBossHelperTest.cs b/.Lib9c.Tests/Helper/AdventureBossHelperTest.cs index 9c0b0917b93..0cf86a902d5 100644 --- a/.Lib9c.Tests/Helper/AdventureBossHelperTest.cs +++ b/.Lib9c.Tests/Helper/AdventureBossHelperTest.cs @@ -137,6 +137,18 @@ out var ncgReward [InlineData("ADD", "-10", 3, 1)] [InlineData("MUL", "2", 0, 0)] [InlineData("ADD", "5", 0, 0)] + // Capped at MaxAdventureBossRewardBoostFactor (5) times the drawn amount, ADD included. + [InlineData("MUL", "4.9", 3, 14)] + [InlineData("MUL", "5", 3, 15)] + [InlineData("MUL", "5.5", 3, 15)] + [InlineData("MUL", "100", 3, 15)] + [InlineData("ADD", "100", 3, 15)] + [InlineData("ADD", "10", 1, 5)] + // Huge values: never negative, never above int.MaxValue. + [InlineData("MUL", "2147483647", int.MaxValue, int.MaxValue)] + [InlineData("ADD", "2147483647", int.MaxValue, int.MaxValue)] + [InlineData("MUL", "2147483647", 1_000_000_000, int.MaxValue)] + [InlineData("MUL", "2147483647", 1, 5)] public void ApplyRewardBoost(string op, string value, int amount, int expected) { var boost = RewardBoost(op, value); diff --git a/.Lib9c.Tests/Helper/SummonHelperTest.cs b/.Lib9c.Tests/Helper/SummonHelperTest.cs index 38a94ec9459..ffe29df7198 100644 --- a/.Lib9c.Tests/Helper/SummonHelperTest.cs +++ b/.Lib9c.Tests/Helper/SummonHelperTest.cs @@ -299,15 +299,25 @@ public void GetSummonRecipeIdsWithGradeGuarantee_AppliesBoost() var equipmentItemRecipeSheet = CreateTestEquipmentItemRecipeSheet(); var boost = CreateBoostRow("ADD", "5"); - // 110 summons guarantee 2 at grade 4; the boost raises that to 7. - var result = SummonHelper.GetSummonRecipeIdsWithGradeGuarantee( + // 110 summons guarantee 2 at grade 4; the boost raises that to 7. The guarantee is + // forced on the last N summons, so the boost must turn the last 7 — not just 2 — into + // grade 4 or above, and leave every summon before them as drawn without it. + var boosted = SummonHelper.GetSummonRecipeIdsWithGradeGuarantee( summonRow, 110, new TestRandom(), equipmentItemSheet, equipmentItemRecipeSheet, boost); + var unboosted = SummonHelper.GetSummonRecipeIdsWithGradeGuarantee( + summonRow, 110, new TestRandom(), equipmentItemSheet, equipmentItemRecipeSheet); - var atLeastMinimumGrade = result.Count(recipeId => + bool AtLeastMinimumGrade(int recipeId) => TryGetItemGradeFromRecipeId(recipeId, equipmentItemSheet, equipmentItemRecipeSheet, out var grade) && - grade >= 4); - Assert.Equal(110, result.Count); - Assert.True(atLeastMinimumGrade >= 7, $"expected at least 7 but got {atLeastMinimumGrade}"); + grade >= 4; + + Assert.Equal(110, boosted.Count); + Assert.All(boosted.Skip(103), recipeId => Assert.True(AtLeastMinimumGrade(recipeId))); + Assert.Equal(unboosted.Take(103), boosted.Take(103)); + + // Without the boost the same seed leaves part of that tail below grade 4, so the + // assertion above fails if the boost is not applied. + Assert.Contains(unboosted.Skip(103), recipeId => !AtLeastMinimumGrade(recipeId)); } private static BoostScheduleSheet.Row CreateBoostRow(string op, string value) diff --git a/Lib9c/Helper/AdventureBossHelper.cs b/Lib9c/Helper/AdventureBossHelper.cs index 09de7d518dc..99c27038a17 100644 --- a/Lib9c/Helper/AdventureBossHelper.cs +++ b/Lib9c/Helper/AdventureBossHelper.cs @@ -27,8 +27,20 @@ public static string GetSeasonAsAddressForm(long season) return $"{season:D40}"; } + /// + /// Share of a season's total bounty, in percent, paid as the raffle reward. + /// public const int RaffleRewardPercent = 5; + /// + /// Upper bound of a or + /// row: a boosted + /// reward amount never exceeds this many times the amount drawn, whatever the row's + /// op and value. It bounds a mistyped value to a fivefold over-reward and keeps + /// boosted amounts added to one inventory slot from overflowing. + /// + public const long MaxAdventureBossRewardBoostFactor = 5; + public const decimal TotalRewardMultiplier = 1.2m; public const decimal FixedRewardRatio = 0.7m; public const decimal RandomRewardRatio = 1m - FixedRewardRatio; @@ -376,13 +388,16 @@ out FungibleAssetValue ncgReward /// /// /// itself when there is no row or nothing to adjust, otherwise a - /// copy whose is adjusted. + /// copy whose is adjusted and + /// kept between 1 and times the drawn + /// amount. /// /// /// Only the granted amount changes: the entry was already drawn, so a boost never adds or /// reorders random draws. A boost adjusts a reward but never creates one, so an entry with /// no amount stays as it is; and it never takes a reward below 1, because granting 0 of - /// an item or a currency fails the action. + /// an item or a currency fails the action. The cap applies to ADD too, so + /// ADD 10 on an amount of 1 grants 5. /// public static AdventureBossSheet.RewardAmountData ApplyRewardBoost( AdventureBossSheet.RewardAmountData reward, @@ -396,7 +411,11 @@ public static AdventureBossSheet.RewardAmountData ApplyRewardBoost( return new AdventureBossSheet.RewardAmountData( reward.ItemType, reward.ItemId, - Math.Max(1, rewardBoost.Apply(reward.Amount))); + // In 64 bits, as Amount × the factor can exceed int.MaxValue; the minimum + // cannot, since Apply(int) is at most int.MaxValue. + (int)Math.Min( + Math.Max(1L, rewardBoost.Apply(reward.Amount)), + reward.Amount * MaxAdventureBossRewardBoostFactor)); } public static IWorld AddExploreRewards(IActionContext context, IWorld states, diff --git a/Lib9c/Helper/WorldBossHelper.cs b/Lib9c/Helper/WorldBossHelper.cs index 4ee8eed8246..708f5cb9d0c 100644 --- a/Lib9c/Helper/WorldBossHelper.cs +++ b/Lib9c/Helper/WorldBossHelper.cs @@ -13,12 +13,34 @@ namespace Nekoyume.Helper { + /// + /// World boss (raid) rules shared by the raid actions and clients: ranks, tickets, rewards + /// and their adjustments. + /// public static class WorldBossHelper { + /// + /// Legacy ticket refill interval in blocks, used by . + /// [Obsolete("Use GameConfigState.DailyWorldBossInterval")] public const long RefillInterval = 7200L; + + /// + /// Number of raid tickets a raider holds after a refill. + /// public const int MaxChallengeCount = 3; + /// + /// Upper bound of a , + /// or + /// row: each boosted amount + /// never exceeds this many times the amount drawn, whatever the row's op and value. + /// It bounds a mistyped value (e.g. a block index pasted into the value cell) to a fivefold + /// over-reward and keeps several boosted amounts added to one inventory slot from + /// overflowing. + /// + public const long MaxWorldBossRewardBoostFactor = 5; + public static int CalculateRank(WorldBossCharacterSheet.Row row, long score) { var rank = 0; @@ -153,15 +175,18 @@ public static (List assets, DictionaryThe amount the reward row and the draws grant. /// /// unchanged when there is no row or nothing is granted; - /// otherwise of it. A result of 0 means the - /// amount is not granted at all. + /// otherwise of it, capped at + /// times . A result + /// of 0 means the amount is not granted at all. /// /// /// A boost adjusts what a reward grants but never adds a kind the row does not grant — a /// row with no crystal stays without crystal under ADD — since a reward that exists /// only during an event reads as one taken away when the event ends. ADD applies to /// every amount separately, i.e. once per distinct rune drawn; use MUL to scale a - /// reward as a whole. + /// reward as a whole. The cap applies to ADD too, so ADD 10 on a single + /// drawn rune grants 5, not 11. Lowering is not bounded: a MUL below 1 or a + /// negative ADD still lowers an amount, down to 0. /// public static int ApplyRewardBoost(BoostScheduleSheet.Row rewardBoost, int amount) { @@ -170,7 +195,11 @@ public static int ApplyRewardBoost(BoostScheduleSheet.Row rewardBoost, int amoun return amount; } - return rewardBoost.Apply(amount); + // In 64 bits: amount × the factor exceeds int.MaxValue for amounts above ~429M, and + // the minimum never does since Apply(int) is at most int.MaxValue. + return (int)Math.Min( + (long)rewardBoost.Apply(amount), + amount * MaxWorldBossRewardBoostFactor); } /// diff --git a/Lib9c/TableData/BoostScheduleSheet.cs b/Lib9c/TableData/BoostScheduleSheet.cs index 1932d0ad677..874b83f2004 100644 --- a/Lib9c/TableData/BoostScheduleSheet.cs +++ b/Lib9c/TableData/BoostScheduleSheet.cs @@ -167,6 +167,11 @@ public static class Targets /// ExploreAdventureBoss and SweepAdventureBoss. target_id is the /// floor number (AdventureBossFloorSheet floor, 1 for the first floor of /// every boss), not the floor row id. + /// + /// The adjusted amount is kept between 1 and + /// AdventureBossHelper.MaxAdventureBossRewardBoostFactor (5) times the drawn + /// amount, ADD included. See AdventureBossHelper.ApplyRewardBoost. + /// /// public const string AdventureBossFloorReward = "ADVENTURE_BOSS_FLOOR_REWARD"; @@ -174,6 +179,7 @@ public static class Targets /// Amount of each AdventureBossFloorFirstRewardSheet reward, granted by /// ExploreAdventureBoss when a floor is cleared for the first time in a season. /// target_id is the floor number, as for . + /// Bounded as (at most 5 times each amount). /// public const string AdventureBossFirstClearReward = "ADVENTURE_BOSS_FIRST_CLEAR_REWARD"; @@ -182,6 +188,12 @@ public static class Targets /// WorldBossBattleRewardSheet reward a Raid grants for its battle. /// target_id is the boss id (WorldBossListSheet.boss_id, e.g. /// 900001), and the block is the one the raid is played in. + /// + /// Each adjusted amount is capped at WorldBossHelper.MaxWorldBossRewardBoostFactor + /// (5) times the drawn amount, ADD included; the same cap holds for + /// and . See + /// WorldBossHelper.ApplyRewardBoost. + /// /// public const string WorldBossBattleReward = "WORLD_BOSS_BATTLE_REWARD"; @@ -191,7 +203,8 @@ public static class Targets /// raises the boss level or by ClaimWordBossKillReward. target_id is the /// boss id (WorldBossListSheet.boss_id), and the block is the one the reward is /// granted in, not the one the boss was killed in: a kill left unclaimed until the - /// event is boosted when it is claimed during the event. + /// event is boosted when it is claimed during the event. Capped at 5 times each amount, + /// as . /// public const string WorldBossKillReward = "WORLD_BOSS_KILL_REWARD"; @@ -201,6 +214,7 @@ public static class Targets /// target_id is the boss id (WorldBossListSheet.boss_id), and the block /// is the one the reward is claimed in, not the one the rank was reached in: a rank /// left unclaimed until the event is boosted when it is claimed during the event. + /// Capped at 5 times each amount, as . /// public const string WorldBossRankReward = "WORLD_BOSS_RANK_REWARD"; @@ -322,7 +336,9 @@ public static class Targets "is reused across seasons, so the row covers every season of that boss within the block range", "WorldBossListSheet", "MUL above 1", - "Each drawn amount is scaled after the draw and floored, so the draws are unchanged.", + "Each drawn amount is scaled after the draw and floored, so the draws are unchanged. The result " + + "is capped at 5 times each drawn amount (ADD too); the value itself is not rejected, so MUL 100 " + + "grants 5×.", "The block is the one the raid is played in. An old client shows the unboosted battle reward " + "(granted correctly).", "7,WORLD_BOSS_BATTLE_REWARD,*,MUL,2,,"), @@ -354,7 +370,8 @@ public static class Targets "floor number (AdventureBossFloorSheet floor, 1 is every boss's first floor), not the row id", "AdventureBossFloorSheet", "MUL above 1", - "Scaled after the draw, floored, never below 1.", + "Scaled after the draw, floored, kept between 1 and 5 times the drawn amount (ADD too); the " + + "value itself is not rejected, so MUL 100 grants 5×.", "NCG and season-end claim rewards are not adjustable. An old client shows the unboosted " + "reward (granted correctly).", "10,ADVENTURE_BOSS_FLOOR_REWARD,*,MUL,2,,"), @@ -364,7 +381,7 @@ public static class Targets "floor number, as ADVENTURE_BOSS_FLOOR_REWARD", "AdventureBossFloorFirstRewardSheet", "MUL above 1", - "Scaled, floored, never below 1.", + "Scaled, floored, kept between 1 and 5 times the configured amount (ADD too).", "As ADVENTURE_BOSS_FLOOR_REWARD.", "11,ADVENTURE_BOSS_FIRST_CLEAR_REWARD,*,MUL,2,,"), new TargetInfo(