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(() => {
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