Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
390139e
Merge pull request #3308 from planetarium/release/1.38.0
ipdae Sep 22, 2026
abd0565
Update EquipmentItemSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
3efbb30
Update EquipmentItemRecipeSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
9859f84
Update EquipmentItemSubRecipeSheetV2.csv from Google Sheets
jaeho0103 Sep 29, 2026
d192359
Update EquipmentItemOptionSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
d71b803
Update EnhancementCostSheetV3.csv from Google Sheets
jaeho0103 Sep 29, 2026
dc26017
Update CrystalEquipmentGrindingSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
cbbfe97
Update SynthesizeSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
e68408d
Update SynthesizeWeightSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
c8a1de0
Update EquipmentSummonSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
32a8a44
Update CostumeSummonSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
1e01512
Update RuneListSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
6386eb0
Update RuneSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
03e21bb
Update RuneOptionSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
aa97784
Update RuneCostSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
ce20238
Update RuneSummonSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
8875d09
Update CostumeItemSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
90422f9
Update CostumeStatSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
cc1646c
Update ItemRequirementSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
45ac72f
Update CollectionSheet.csv from Google Sheets
jaeho0103 Sep 29, 2026
4699362
test: G9 합성 테스트를 실제 시트 데이터 기준으로 전환
ipdae Sep 29, 2026
2c5b0b6
Merge pull request #3309 from jaeho0103/update/tablecsv-20260929-1850
ipdae Sep 29, 2026
10f2669
fix: 적 초기 스탯 보정값을 long 으로 파싱
ipdae Oct 6, 2026
fd9aefc
Merge pull request #3310 from planetarium/yang/stage-sheet-long-modifier
ipdae Oct 6, 2026
e70e1c6
feat: BoostScheduleSheet 로 소환 보장 개수를 기간 한정 조정
ipdae Oct 8, 2026
14414f1
feat: BoostScheduleSheet target_id 에 구간 문법(1~50) 추가
ipdae Oct 8, 2026
9d5845a
feat: BoostScheduleSheet 로 합성 재료 수량 조정
ipdae Oct 8, 2026
67e2e90
feat: BoostScheduleSheet 로 월드보스 보상 조정
ipdae Oct 8, 2026
c2ed78f
feat: BoostScheduleSheet 로 룬 강화 비용 조정
ipdae Oct 8, 2026
8fb0afe
feat: BoostScheduleSheet 로 어드벤처보스 보상 조정
ipdae Oct 8, 2026
df65975
feat: BoostScheduleSheet 로 스테이지 보상 조정
ipdae Oct 8, 2026
31bd391
fix: 룬 강화 할인을 소환 룬으로 한정
ipdae Oct 12, 2026
2a2b852
refactor: 룬 강화 부스트를 해석된 타입으로만 받게
ipdae Oct 12, 2026
06d5d34
feat: 스테이지 아이템 부스트에서 1개 드랍은 2개로
ipdae Oct 12, 2026
16f6a18
test: target_id 구간 파싱 경계 케이스 고정
ipdae Oct 12, 2026
fb55de0
fix: 룬 강화 부스트를 해석한 룬에만 적용
ipdae Oct 12, 2026
2d70c54
refactor: 룬 강화 비용 부스트 호출을 named argument 로
ipdae Oct 12, 2026
54a5805
ci: feature/* 브랜치도 dev NuGet 패키지 발행
ipdae Oct 12, 2026
7b1fad8
Merge pull request #3311 from planetarium/yang/boost-schedule-sheet
ipdae Oct 12, 2026
660b3d6
Merge pull request #3312 from planetarium/yang/boost-synthesize
ipdae Oct 12, 2026
ed7da37
Merge remote-tracking branch 'origin/feature/boost-schedule' into yan…
ipdae Oct 12, 2026
e4ff220
Merge branch 'yang/boost-rune-enhancement' into yang/boost-worldboss-…
ipdae Oct 12, 2026
020131c
Merge branch 'yang/boost-worldboss-reward' into yang/boost-adventureb…
ipdae Oct 12, 2026
84d5913
Merge branch 'yang/boost-adventureboss-reward' into yang/boost-stage-…
ipdae Oct 12, 2026
ad78d12
Merge pull request #3317 from planetarium/yang/publish-feature-branches
ipdae Oct 12, 2026
5644833
feat: BoostScheduleSheet 레버별 작성 안내(Targets.Infos)
ipdae Oct 12, 2026
9c4ddb6
fix: 레버 안내 리뷰 반영 — 소환 11/110 동시 적용·0까지 하향, 스테이지 결과 상한 10배, 구클라 표시 영향,…
ipdae Oct 12, 2026
b7b4560
Merge pull request #3313 from planetarium/yang/boost-rune-enhancement
ipdae Oct 12, 2026
3edbeb1
Merge pull request #3314 from planetarium/yang/boost-worldboss-reward
ipdae Oct 12, 2026
8030247
Merge pull request #3315 from planetarium/yang/boost-adventureboss-re…
ipdae Oct 12, 2026
7ecb346
Merge pull request #3316 from planetarium/yang/boost-stage-reward
ipdae Oct 12, 2026
db99edb
Merge remote-tracking branch 'origin/feature/boost-schedule' into yan…
ipdae Oct 12, 2026
610aeb0
Merge pull request #3318 from planetarium/yang/boost-target-descriptions
ipdae Oct 12, 2026
07c242c
feat: BoostScheduleSheet 로 강화 망치 exp 조정 (ENHANCEMENT_HAMMER_EXP)
ipdae Oct 12, 2026
35f6896
test: 망치 부스트가 도달 레벨·NCG 비용을 바꾸고 표시 비용만 가진 아바타는 실패함을 고정, long 상한 분기, M…
ipdae Oct 12, 2026
4b8f723
Merge pull request #3319 from planetarium/yang/boost-enhancement-hammer
ipdae Oct 12, 2026
f56d380
Merge pull request #3320 from planetarium/feature/boost-schedule
ipdae Oct 12, 2026
62f95e6
chore: bump to 1.39.0
ipdae Oct 12, 2026
062ca5e
fix(boost): 월드보스·어드벤처보스 보상 부스트에 5배 상한 (#3321)
ipdae Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
284 changes: 284 additions & 0 deletions .Lib9c.Tests/Action/AdventureBoss/ExploreAdventureBossTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ namespace Lib9c.Tests.Action.AdventureBoss

public class ExploreAdventureBossTest
{
// See UseBoostTestBoss.
private const int BoostTestAdventureBossId = 2;

private static readonly Dictionary<string, string> Sheets =
TableSheetsImporter.ImportSheets();

Expand Down Expand Up @@ -343,6 +346,287 @@ 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);
}

// 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()
{
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<Dictionary<string, BigInteger>>();
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,
string value = "2") =>
"id,target,target_id,op,value,start_block,end_block\n" +
$"1,{target},{targetId},MUL,{value},{start},{end}\n";

private static Dictionary<string, BigInteger> Scale(
Dictionary<string, BigInteger> 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<string, BigInteger> Combine(
Dictionary<string, BigInteger> left,
Dictionary<string, BigInteger> 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<string, BigInteger> FirstClearRewards(
int adventureBossId,
int fromFloor,
int toFloor)
{
var rewards = new Dictionary<string, BigInteger>();
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<string, BigInteger> 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<CollectionSheet>(),
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<Guid>(),
Equipments = new List<Guid>(),
Foods = new List<Guid>(),
RuneInfos = new List<RuneSlotInfo>(),
}.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<string, BigInteger> Granted(IWorld state)
{
var granted = new Dictionary<string, BigInteger>();
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);
Expand Down
Loading
Loading