feat(rewards): add completed, background and banner effect items
Build, Test & Push Core Service / quality-check (push) Successful in 1m4s
Build, Test & Push Core Service / docker (push) Successful in 1m31s

This commit is contained in:
lobo committed 2026-10-01 15:09:25 +02:00
1 parent 349fa40272
commit a12ed20705
7 files changed
+42 -8

No files matched your search

+2 -1
View File
@@ -63,7 +63,8 @@ To Be Played is split into three repositories:
snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for
a finish, at most 3 per ISO week. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant a finish, at most 3 per ISO week. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
for finishes from before the status log. Shop: a whitelist of 8 looks (`ShopItem`); a `BUY` row proves ownership, for finishes from before the status log. Shop: a whitelist of 28 looks (`ShopItem`) in seven slots, including animated completed effects, page backgrounds and banner
backgrounds (V27 widens `reward_equipped.slot`); a `BUY` row proves ownership,
`reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role` `reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role`
claim of the profile ticket for visitors). claim of the profile ticket for visitors).
- **Wishlist**: synced from Steam with store price and discount. - **Wishlist**: synced from Steam with store price and discount.
@@ -1,5 +1,5 @@
package core_service.modules.rewards.domain; package core_service.modules.rewards.domain;
public enum LookSlot { public enum LookSlot {
AVATAR_BORDER, BADGE, ACCENT, BANNER_FRAME AVATAR_BORDER, BADGE, ACCENT, BANNER_FRAME, COMPLETED_EFFECT, PAGE_BACKGROUND, BANNER_BACKGROUND
} }
@@ -6,6 +6,8 @@ import java.util.stream.Collectors;
/** /**
* The whitelist of looks. The frontend maps a code to its visuals; nothing here is ever uploaded CSS. * The whitelist of looks. The frontend maps a code to its visuals; nothing here is ever uploaded CSS.
* {@code FX_} effects show on the cover of a COMPLETED game and on its details page, {@code BG_} on the page
* background, {@code BN_} on the dashboard and profile banner.
*/ */
public enum ShopItem { public enum ShopItem {
BORDER_GOLD(LookSlot.AVATAR_BORDER, 150), BORDER_GOLD(LookSlot.AVATAR_BORDER, 150),
@@ -15,7 +17,27 @@ public enum ShopItem {
ACCENT_GOLD(LookSlot.ACCENT, 200), ACCENT_GOLD(LookSlot.ACCENT, 200),
ACCENT_TEAL(LookSlot.ACCENT, 200), ACCENT_TEAL(LookSlot.ACCENT, 200),
FRAME_GOLD(LookSlot.BANNER_FRAME, 150), FRAME_GOLD(LookSlot.BANNER_FRAME, 150),
FRAME_NEON(LookSlot.BANNER_FRAME, 200); FRAME_NEON(LookSlot.BANNER_FRAME, 200),
FX_GOLD_SHINE(LookSlot.COMPLETED_EFFECT, 300),
FX_NEON_PULSE(LookSlot.COMPLETED_EFFECT, 300),
FX_HOLO(LookSlot.COMPLETED_EFFECT, 350),
FX_EMBER(LookSlot.COMPLETED_EFFECT, 300),
BG_TRIANGLES(LookSlot.PAGE_BACKGROUND, 400),
BG_STARFIELD(LookSlot.PAGE_BACKGROUND, 400),
BG_PIXELS(LookSlot.PAGE_BACKGROUND, 400),
BG_AURORA(LookSlot.PAGE_BACKGROUND, 450),
BG_MESH(LookSlot.PAGE_BACKGROUND, 450),
BG_GRID(LookSlot.PAGE_BACKGROUND, 450),
BG_WAVES(LookSlot.PAGE_BACKGROUND, 400),
BG_BOKEH(LookSlot.PAGE_BACKGROUND, 400),
BN_TRIANGLES(LookSlot.BANNER_BACKGROUND, 300),
BN_STARFIELD(LookSlot.BANNER_BACKGROUND, 300),
BN_PIXELS(LookSlot.BANNER_BACKGROUND, 300),
BN_AURORA(LookSlot.BANNER_BACKGROUND, 300),
BN_MESH(LookSlot.BANNER_BACKGROUND, 300),
BN_GRID(LookSlot.BANNER_BACKGROUND, 300),
BN_WAVES(LookSlot.BANNER_BACKGROUND, 300),
BN_BOKEH(LookSlot.BANNER_BACKGROUND, 300);
private final LookSlot slot; private final LookSlot slot;
private final int price; private final int price;
@@ -0,0 +1,2 @@
-- BANNER_BACKGROUND is 17 characters.
ALTER TABLE reward_equipped ALTER COLUMN slot TYPE VARCHAR(32);
@@ -1,6 +1,7 @@
package core_service.modules.rewards.api; package core_service.modules.rewards.api;
import core_service.configuration.security.ClientHeaderFilter; import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.rewards.domain.ShopItem;
import core_service.modules.rewards.service.RewardService; import core_service.modules.rewards.service.RewardService;
import core_service.shared.security.Role; import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
@@ -81,7 +82,7 @@ class RewardControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.balance").value(30)) .andExpect(jsonPath("$.balance").value(30))
.andExpect(jsonPath("$.history[0].kind").value("FINISHED")) .andExpect(jsonPath("$.history[0].kind").value("FINISHED"))
.andExpect(jsonPath("$.history[0].gameName").value("Game " + GAME)) .andExpect(jsonPath("$.history[0].gameName").value("Game " + GAME))
.andExpect(jsonPath("$.catalog.length()").value(8)) .andExpect(jsonPath("$.catalog.length()").value(ShopItem.values().length))
.andExpect(jsonPath("$.owned").isEmpty()); .andExpect(jsonPath("$.owned").isEmpty());
setStatus(GAME, "BACKLOG"); setStatus(GAME, "BACKLOG");
@@ -212,7 +213,7 @@ class RewardControllerTest extends AbstractIntegrationTest {
@Test @Test
void admin_OwnsEveryItem_NeverBuysAndFallsBackToDefaultAsAUser() throws Exception { void admin_OwnsEveryItem_NeverBuysAndFallsBackToDefaultAsAUser() throws Exception {
mockMvc.perform(asAdmin(get("/api/rewards/wallet"))) mockMvc.perform(asAdmin(get("/api/rewards/wallet")))
.andExpect(jsonPath("$.owned.length()").value(8)); .andExpect(jsonPath("$.owned.length()").value(ShopItem.values().length));
mockMvc.perform(asAdmin(post("/api/rewards/purchases")).header(ClientHeaderFilter.CLIENT_HEADER, "test") mockMvc.perform(asAdmin(post("/api/rewards/purchases")).header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"item\":\"FRAME_GOLD\"}")) .contentType(MediaType.APPLICATION_JSON).content("{\"item\":\"FRAME_GOLD\"}"))
.andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("ALREADY_OWNED")); .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("ALREADY_OWNED"));
@@ -25,9 +25,10 @@ class LooksTest extends AbstractUnitTest {
@Test @Test
void wearable_DropsLooksThatAreNotOwnedOrNotInTheCatalog() { void wearable_DropsLooksThatAreNotOwnedOrNotInTheCatalog() {
Map<LookSlot, String> equipped = Map.of(LookSlot.AVATAR_BORDER, "BORDER_GOLD", LookSlot.BADGE, "BADGE_PILE_SLAYER", Map<LookSlot, String> equipped = Map.of(LookSlot.AVATAR_BORDER, "BORDER_GOLD", LookSlot.BADGE, "BADGE_PILE_SLAYER",
LookSlot.ACCENT, "ACCENT_NEON_PINK"); LookSlot.ACCENT, "ACCENT_NEON_PINK", LookSlot.BANNER_BACKGROUND, "BN_GRID");
assertThat(Looks.wearable(equipped, Set.of("BORDER_GOLD", "ACCENT_NEON_PINK"))) assertThat(Looks.wearable(equipped, Set.of("BORDER_GOLD", "ACCENT_NEON_PINK", "BN_GRID")))
.containsOnly(Map.entry(LookSlot.AVATAR_BORDER, "BORDER_GOLD")); .containsOnly(Map.entry(LookSlot.AVATAR_BORDER, "BORDER_GOLD"),
Map.entry(LookSlot.BANNER_BACKGROUND, "BN_GRID"));
} }
} }
@@ -99,4 +99,11 @@ class RewardLedgerRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.equipped(USER)).containsOnlyKeys(LookSlot.ACCENT); assertThat(repository.equipped(USER)).containsOnlyKeys(LookSlot.ACCENT);
} }
@Test
void equip_TheLongestSlotNameFits() {
repository.equip(USER, LookSlot.BANNER_BACKGROUND, "BN_GRID");
assertThat(repository.equipped(USER)).containsOnly(Map.entry(LookSlot.BANNER_BACKGROUND, "BN_GRID"));
}
} }