From 75bc91be1baa106bf92e62a280dde21383ebefc2 Mon Sep 17 00:00:00 2001 From: Ada Date: Thu, 30 Apr 2026 11:14:26 -0400 Subject: [PATCH] Wire VipGift dispatch for vote rewards via template clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VipGift reward type now actually grants — earlier this was a stub that threw NotImplementedException so admins selecting it got a clear message rather than a silent no-op. Approach: a "template" is just a vip_gift row whose player_id is the sentinel "_template_". Admins build templates through the existing VIP Gifts admin UI by typing that string as the player_id; the VoteRewards feature looks them up by name and clones them per voter: 1. GetTemplateByName(name) — finds the template (lowest id wins on dups) 2. Read template's items + commands BEFORE insert so a partial failure doesn't leave a half-built gift in the voter's pending list 3. Insert a new vip_gifts row keyed to the voter's player_id 4. Mirror the items + commands junctions into the new row 5. Voter claims with /vip on next login Sentinel-prefix instead of an is_template column means no schema migration and no junction-table scaffolding work — templates just don't show up in any real player's pending-gift list because GetByPlayerId is exact-match. Frontend changes: - VIP gift template-name field is no longer disabled - Dropdown label is plain "VIP Gift" (was "VIP Gift (not yet implemented)") - New hint string explains the "_template_" sentinel convention so admins know exactly what to put in the player_id field when building the template CD Key dispatch still throws NotImplementedException — that's the next slot. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/i18n/locales/en.ts | 3 +- frontend/src/i18n/locales/ja.ts | 3 +- frontend/src/i18n/locales/ko.ts | 3 +- frontend/src/i18n/locales/zh-CN.ts | 3 +- frontend/src/i18n/locales/zh-TW.ts | 3 +- frontend/src/views/SettingsView.vue | 4 +- .../Data/Repositories/VipGiftRepository.cs | 35 +++++++++++ .../Features/VoteRewardsFeature.cs | 62 ++++++++++++++++--- 8 files changed, 100 insertions(+), 16 deletions(-) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 468d209..b121b19 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -814,10 +814,11 @@ const en = { voteRewardsPollIntervalHint: 'How often the background sweep checks this provider for new votes. 5 min is a good default.', voteRewardsRewardType: 'Reward type', voteRewardsRewardPoints: 'Points', - voteRewardsRewardVipGift: 'VIP Gift (not yet implemented)', + voteRewardsRewardVipGift: 'VIP Gift', voteRewardsRewardCdKey: 'CD Key (not yet implemented)', voteRewardsPointsAmount: 'Points to grant per vote', voteRewardsVipGiftTemplate: 'VIP gift template name', + voteRewardsVipGiftTemplateHint: 'The Name of an existing VIP gift whose player_id is the sentinel "_template_". Create it via the VIP Gifts admin tab — its items + commands will be cloned for each voter, who claims them with /vip on next login.', voteRewardsCdKeyTemplate: 'CD key template ID', voteRewardsBroadcastTemplate: 'In-game broadcast template', voteRewardsBroadcastHint: 'Sent to the whole server when an online player\'s vote is granted. Tokens: {player}, {reward}. Empty = silent.', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 1e23e36..fe6e49b 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -818,10 +818,11 @@ const ja: Messages = { voteRewardsPollIntervalHint: 'How often the background sweep checks this provider for new votes. 5 min is a good default.', voteRewardsRewardType: 'Reward type', voteRewardsRewardPoints: 'Points', - voteRewardsRewardVipGift: 'VIP Gift (not yet implemented)', + voteRewardsRewardVipGift: 'VIP Gift', voteRewardsRewardCdKey: 'CD Key (not yet implemented)', voteRewardsPointsAmount: 'Points to grant per vote', voteRewardsVipGiftTemplate: 'VIP gift template name', + voteRewardsVipGiftTemplateHint: 'The Name of an existing VIP gift whose player_id is the sentinel "_template_". Create it via the VIP Gifts admin tab — its items + commands will be cloned for each voter, who claims them with /vip on next login.', voteRewardsCdKeyTemplate: 'CD key template ID', voteRewardsBroadcastTemplate: 'In-game broadcast template', voteRewardsBroadcastHint: 'Sent to the whole server when an online player\'s vote is granted. Tokens: {player}, {reward}. Empty = silent.', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index e3b7f24..bfa78b3 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -818,10 +818,11 @@ const ko: Messages = { voteRewardsPollIntervalHint: 'How often the background sweep checks this provider for new votes. 5 min is a good default.', voteRewardsRewardType: 'Reward type', voteRewardsRewardPoints: 'Points', - voteRewardsRewardVipGift: 'VIP Gift (not yet implemented)', + voteRewardsRewardVipGift: 'VIP Gift', voteRewardsRewardCdKey: 'CD Key (not yet implemented)', voteRewardsPointsAmount: 'Points to grant per vote', voteRewardsVipGiftTemplate: 'VIP gift template name', + voteRewardsVipGiftTemplateHint: 'The Name of an existing VIP gift whose player_id is the sentinel "_template_". Create it via the VIP Gifts admin tab — its items + commands will be cloned for each voter, who claims them with /vip on next login.', voteRewardsCdKeyTemplate: 'CD key template ID', voteRewardsBroadcastTemplate: 'In-game broadcast template', voteRewardsBroadcastHint: 'Sent to the whole server when an online player\'s vote is granted. Tokens: {player}, {reward}. Empty = silent.', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index e7b548d..acc5a15 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -818,10 +818,11 @@ const zhCN: Messages = { voteRewardsPollIntervalHint: 'How often the background sweep checks this provider for new votes. 5 min is a good default.', voteRewardsRewardType: 'Reward type', voteRewardsRewardPoints: 'Points', - voteRewardsRewardVipGift: 'VIP Gift (not yet implemented)', + voteRewardsRewardVipGift: 'VIP Gift', voteRewardsRewardCdKey: 'CD Key (not yet implemented)', voteRewardsPointsAmount: 'Points to grant per vote', voteRewardsVipGiftTemplate: 'VIP gift template name', + voteRewardsVipGiftTemplateHint: 'The Name of an existing VIP gift whose player_id is the sentinel "_template_". Create it via the VIP Gifts admin tab — its items + commands will be cloned for each voter, who claims them with /vip on next login.', voteRewardsCdKeyTemplate: 'CD key template ID', voteRewardsBroadcastTemplate: 'In-game broadcast template', voteRewardsBroadcastHint: 'Sent to the whole server when an online player\'s vote is granted. Tokens: {player}, {reward}. Empty = silent.', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 6f9e613..87c110e 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -818,10 +818,11 @@ const zhTW: Messages = { voteRewardsPollIntervalHint: 'How often the background sweep checks this provider for new votes. 5 min is a good default.', voteRewardsRewardType: 'Reward type', voteRewardsRewardPoints: 'Points', - voteRewardsRewardVipGift: 'VIP Gift (not yet implemented)', + voteRewardsRewardVipGift: 'VIP Gift', voteRewardsRewardCdKey: 'CD Key (not yet implemented)', voteRewardsPointsAmount: 'Points to grant per vote', voteRewardsVipGiftTemplate: 'VIP gift template name', + voteRewardsVipGiftTemplateHint: 'The Name of an existing VIP gift whose player_id is the sentinel "_template_". Create it via the VIP Gifts admin tab — its items + commands will be cloned for each voter, who claims them with /vip on next login.', voteRewardsCdKeyTemplate: 'CD key template ID', voteRewardsBroadcastTemplate: 'In-game broadcast template', voteRewardsBroadcastHint: 'Sent to the whole server when an online player\'s vote is granted. Tokens: {player}, {reward}. Empty = silent.', diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index e0239da..349299a 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -1374,8 +1374,8 @@ onMounted(() => {
- - {{ t('settings.voteRewardsRewardVipGift') }} + + {{ t('settings.voteRewardsVipGiftTemplateHint') }}
diff --git a/src/KitsuneCommand/Data/Repositories/VipGiftRepository.cs b/src/KitsuneCommand/Data/Repositories/VipGiftRepository.cs index 1067ba1..4fe1ee0 100644 --- a/src/KitsuneCommand/Data/Repositories/VipGiftRepository.cs +++ b/src/KitsuneCommand/Data/Repositories/VipGiftRepository.cs @@ -22,10 +22,26 @@ public interface IVipGiftRepository void SetGiftItems(int giftId, IEnumerable itemDefinitionIds); void SetGiftCommands(int giftId, IEnumerable commandDefinitionIds); + // Templates: a "template" gift is a vip_gift row with the sentinel + // player_id "_template_". Admins create one via the regular VIP Gifts + // admin UI by typing "_template_" as the player_id; the VoteRewards + // feature looks them up by name and clones them for voters. + VipGift GetTemplateByName(string name); + // Claiming void MarkAsClaimed(int giftId); } + /// + /// The sentinel player_id used to mark a vip_gift row as a template that + /// other features (e.g. VoteRewards) can clone from. Underscore-prefixed + /// to avoid colliding with any real Steam_/Epic_ cross-platform id. + /// + public static class VipGiftSentinels + { + public const string TemplatePlayerId = "_template_"; + } + public class VipGiftRepository : IVipGiftRepository { private readonly DbConnectionFactory _db; @@ -170,6 +186,25 @@ public void SetGiftCommands(int giftId, IEnumerable commandDefinitionIds) } } + // ─── Templates ──────────────────────────────────────────── + + /// + /// Returns the first template gift matching the given name. If multiple + /// rows share the same name + sentinel player_id (admin error / accident), + /// the lowest id wins so behavior is deterministic across requests. + /// Returns null if no template with that name exists. + /// + public VipGift GetTemplateByName(string name) + { + using var conn = _db.CreateConnection(); + return conn.QueryFirstOrDefault(@" + SELECT * FROM vip_gifts + WHERE player_id = @TemplateId AND name = @Name + ORDER BY id ASC + LIMIT 1", + new { TemplateId = VipGiftSentinels.TemplatePlayerId, Name = name }); + } + // ─── Claiming ──────────────────────────────────────────── public void MarkAsClaimed(int giftId) diff --git a/src/KitsuneCommand/Features/VoteRewardsFeature.cs b/src/KitsuneCommand/Features/VoteRewardsFeature.cs index 68d1f83..e235d5a 100644 --- a/src/KitsuneCommand/Features/VoteRewardsFeature.cs +++ b/src/KitsuneCommand/Features/VoteRewardsFeature.cs @@ -9,8 +9,6 @@ using KitsuneCommand.Data.Repositories; using KitsuneCommand.Features.VoteRewards; using KitsuneCommand.Features.VoteRewards.Providers; -// VipGift template-clone is deferred to v1.5; the dispatch case currently throws -// NotImplementedException, so we don't carry the entity import yet. using Newtonsoft.Json; namespace KitsuneCommand.Features @@ -35,6 +33,7 @@ public class VoteRewardsFeature : FeatureBase private readonly ISettingsRepository _settingsRepo; private readonly IVoteGrantRepository _grantRepo; private readonly IPointsRepository _pointsRepo; + private readonly IVipGiftRepository _vipGiftRepo; private readonly LivePlayerManager _playerManager; private const string SettingsKey = "VoteRewards"; @@ -55,12 +54,14 @@ public VoteRewardsFeature( ISettingsRepository settingsRepo, IVoteGrantRepository grantRepo, IPointsRepository pointsRepo, + IVipGiftRepository vipGiftRepo, LivePlayerManager playerManager) : base(eventBus, config) { _settingsRepo = settingsRepo; _grantRepo = grantRepo; _pointsRepo = pointsRepo; + _vipGiftRepo = vipGiftRepo; _playerManager = playerManager; } @@ -366,13 +367,56 @@ private string DispatchReward(string steamId, string playerName, VoteProviderSet } case VoteRewardType.VipGift: - // Reserved for v1.5 — needs a template-clone path: look up the - // template VIP gift by name, insert a copy for this voter, and - // mirror its item + command junctions into the new row. The - // shape and storage are ready; the clone wiring is pending. - // Admins selecting this today get an explicit error rather - // than a silent no-op. - throw new NotImplementedException("VIP-gift vote rewards are not yet wired up. Use Points for now."); + { + // Clone a pre-built template gift for this voter. The admin + // creates the template via the regular VIP Gifts admin UI + // by typing the sentinel player_id "_template_" — that keeps + // it out of any real player's pending-gift list, but lets + // VoteRewards find it by name and copy it. + if (string.IsNullOrWhiteSpace(cfg.VipGiftTemplateName)) + { + throw new InvalidOperationException( + "VipGiftTemplateName is empty — set the template name in the Vote Rewards settings."); + } + + var template = _vipGiftRepo.GetTemplateByName(cfg.VipGiftTemplateName); + if (template == null) + { + throw new InvalidOperationException( + $"No VIP gift template named '{cfg.VipGiftTemplateName}' (player_id = '{VipGiftSentinels.TemplatePlayerId}'). " + + $"Create it via the VIP Gifts admin UI first."); + } + + // Read the template's items + commands BEFORE insert, so a + // partial failure doesn't leave a half-built gift in the + // voter's pending list (the items would be missing). + var templateItems = _vipGiftRepo.GetItemsForGift(template.Id).Select(i => i.Id).ToList(); + var templateCommands = _vipGiftRepo.GetCommandsForGift(template.Id).Select(c => c.Id).ToList(); + + var voterGift = new VipGift + { + PlayerId = playerId, + PlayerName = resolvedName, + Name = template.Name, + Description = string.IsNullOrWhiteSpace(template.Description) + ? $"Vote reward from {cfg.Key}" + : template.Description, + // One-time gift — claim_period stays null. If a server + // wants repeatable vote rewards, that's a per-vote + // grant, not a per-claim period. + ClaimPeriod = null, + }; + var newId = _vipGiftRepo.Insert(voterGift); + _vipGiftRepo.SetGiftItems(newId, templateItems); + _vipGiftRepo.SetGiftCommands(newId, templateCommands); + + var itemCount = templateItems.Count; + var commandCount = templateCommands.Count; + var summary = itemCount == 0 && commandCount == 0 + ? "(empty template — fix the template before next vote)" + : $"{itemCount} item(s) + {commandCount} command(s)"; + return $"VIP gift '{template.Name}' [{summary}] (claim with /vip)"; + } case VoteRewardType.CdKey: // Reserved — needs CdKey template lookup wiring. Leaving the