# Lucky Gift Activity Service Development Guide

本文档定义幸运礼物在 `activity-service` 中的开发落地。产品规则见 `docs/幸运礼物产品方案.md`。

## Ownership

幸运礼物归属 `activity-service`，但金币账务仍归属 `wallet-service`，房间状态仍归属 `room-service`。

| Capability | Owner | Notes |
| --- | --- | --- |
| 幸运礼物规则配置 | `activity-service` | 概率表、奖档、启停、版本 |
| 三层奖池 | `activity-service` | 平台、房间、礼物资金水位 |
| 体验池 | `activity-service` | 新手、中级、高级候选奖档和权重 |
| 全站 RTP 控制窗口 | `activity-service` | 控全站基础返奖总成本 |
| 礼物 RTP 子窗口 | `activity-service` | 控单个幸运礼物基础返奖成本 |
| 房间气氛池 | `activity-service` | 控房间爆点、共享奖励和表现预算 |
| 活动预算池 | `activity-service` | 控限时活动补贴，不污染基础 RTP |
| 抽奖决策 | `activity-service` | 先过滤奖档，再执行随机 |
| 风控阈值 | `activity-service` | 用户、设备、房间、主播、活动周期 |
| 金币扣费 | `wallet-service` | `room-service` 送礼前同步扣费 |
| 金币返还 | `wallet-service` | 需要新增幂等入账 RPC |
| 房间热度和榜单 | `room-service` | Room Cell 内串行变更 |
| 房间消息投递 | 腾讯云 IM | 承载客户端群消息和历史 |

`activity-service` 不直接修改用户金币余额，不直接写 Room Cell，不直接保存 WebSocket 连接。

## Target Flow

幸运礼物在普通 `SendGift` 链路上增加一次活动决策。

```mermaid
sequenceDiagram
  participant C as Client
  participant G as gateway-service
  participant R as room-service
  participant W as wallet-service
  participant A as activity-service
  participant I as Tencent Cloud IM

  C->>G: HTTP SendGift
  G->>R: gRPC SendGift
  R->>A: CheckLuckyGift
  A-->>R: enabled + rule_version
  R->>W: DebitGift
  W-->>R: billing_receipt_id
  R->>A: ExecuteLuckyGiftDraw
  A->>A: lock pools + filter tiers + draw + write log
  A->>W: CreditLuckyReward
  W-->>A: credit_receipt_id
  A-->>R: draw_result
  R->>R: Room Cell apply gift + lucky presentation
  R->>I: PublishRoomEvent via REST
  R-->>G: SendGiftResponse
  G-->>C: JSON result
```

如果 `CreditLuckyReward` 暂时失败，`activity-service` 返回 `reward_status=PENDING`，并由本服务 outbox 重试入账。`room-service` 不能广播“已到账”，只能广播普通礼物或“奖励处理中”。

## RPC Contracts

建议新增 `api/proto/activity/v1/lucky_gift.proto`。

```protobuf
syntax = "proto3";

package hyapp.activity.v1;

option go_package = "hyapp/api/proto/activity/v1;activityv1";

message LuckyGiftMeta {
  string request_id = 1;
  string command_id = 2;
  string room_id = 3;
  int64 sender_user_id = 4;
  int64 target_user_id = 5;
  string device_id = 6;
  int64 sent_at_ms = 7;
}

message CheckLuckyGiftRequest {
  LuckyGiftMeta meta = 1;
  string gift_id = 2;
  int32 gift_count = 3;
  int64 coin_spent = 4;
  int64 heat_value = 5;
}

message CheckLuckyGiftResponse {
  bool enabled = 1;
  string rule_version = 2;
  string reject_reason = 3;
}

message ExecuteLuckyGiftDrawRequest {
  LuckyGiftMeta meta = 1;
  string gift_id = 2;
  int32 gift_count = 3;
  int64 coin_spent = 4;
  int64 heat_value = 5;
  string billing_receipt_id = 6;
  string rule_version = 7;
}

message RewardTierResult {
  string tier_id = 1;
  int64 base_reward_coins = 2;
  int64 room_atmosphere_reward_coins = 3;
  int64 activity_subsidy_coins = 4;
  int64 effective_reward_coins = 5;
  string tier_type = 6;
  string budget_sources_json = 7;
}

message ExecuteLuckyGiftDrawResponse {
  string draw_id = 1;
  string rule_version = 2;
  bool hit = 3;
  RewardTierResult tier = 4;
  string reward_status = 5;
  string credit_receipt_id = 6;
  string reject_reason = 7;
}

service LuckyGiftService {
  rpc CheckLuckyGift(CheckLuckyGiftRequest) returns (CheckLuckyGiftResponse);
  rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
}
```

`wallet-service` 需要新增金币返还接口，避免 `activity-service` 直接写账务库。

```protobuf
message CreditLuckyRewardRequest {
  string reward_id = 1;
  string command_id = 2;
  string draw_id = 3;
  string room_id = 4;
  int64 user_id = 5;
  int64 effective_reward_coins = 6;
  int64 base_reward_coins = 7;
  int64 room_atmosphere_reward_coins = 8;
  int64 activity_subsidy_coins = 9;
  string reason = 10;
  string budget_sources_json = 11;
}

message CreditLuckyRewardResponse {
  string credit_receipt_id = 1;
  int64 balance_after = 2;
}
```

## Database Tables

`activity-service` 需要独立 MySQL schema。Redis 只用于短期计数和热缓存，不能作为抽奖结果的唯一存储。

### `lucky_gift_rules`

保存幸运礼物主规则。

| Column | Type | Notes |
| --- | --- | --- |
| `id` | bigint | primary key |
| `gift_id` | varchar | unique with `rule_version` |
| `rule_version` | varchar | immutable after publish |
| `gift_price` | bigint | single gift price in coins |
| `pool_rate_ppm` | int | pool inflow rate, ppm |
| `target_payout_rate_ppm` | int | expected long-term payout |
| `target_rtp_ppm` | int | controlled RTP target, e.g. 950000 for 95% |
| `control_window_draws` | int | paid draws per RTP control window |
| `gift_rtp_window_draws` | int | paid draws per gift-level RTP sub-window |
| `novice_draw_limit` | bigint | inclusive max paid draw count for novice pool |
| `intermediate_draw_limit` | bigint | inclusive max paid draw count for intermediate pool |
| `room_atmosphere_rule_json` | json | room burst, shared reward, and presentation policy |
| `activity_budget_id` | varchar | optional active subsidy budget |
| `max_single_payout` | bigint | max reward per draw |
| `max_user_daily_payout` | bigint | user daily cap |
| `max_room_hourly_payout` | bigint | room hourly cap |
| `status` | varchar | draft, active, paused, retired |
| `effective_at_ms` | bigint | version effective time |
| `created_at_ms` | bigint | creation time |
| `updated_at_ms` | bigint | update time |

### `lucky_reward_tiers`

保存概率表奖档。

| Column | Type | Notes |
| --- | --- | --- |
| `id` | bigint | primary key |
| `gift_id` | varchar | gift id |
| `rule_version` | varchar | rule version |
| `experience_pool` | varchar | novice, intermediate, advanced |
| `tier_id` | varchar | none, small_1x, medium_5x |
| `reward_type` | varchar | none, fixed, multiplier |
| `reward_value` | bigint | fixed coins or multiplier base |
| `base_weight` | int | relative selection weight inside the experience pool |
| `budget_source` | varchar | base_rtp, room_atmosphere, activity_subsidy, presentation_only |
| `presentation_type` | varchar | none, room_message, floating_screen, shared_drop |
| `shared_reward_policy_json` | json | optional shared reward split for room atmosphere |
| `min_pool_balance` | bigint | activation waterline |
| `enabled` | bool | tier switch |

All tier weights in one `(gift_id, rule_version, experience_pool)` group should be positive and internally normalizable. They are not the source of truth for total RTP; they only shape the visible distribution inside the RTP control window.

### `lucky_rtp_windows`

保存 RTP 控制窗口。窗口行必须在抽奖事务内和三层奖池一起锁定，避免并发抽奖同时消费同一段目标支出。

| Column | Type | Notes |
| --- | --- | --- |
| `scope_type` | varchar | global, gift |
| `scope_id` | varchar | global or gift_id |
| `window_index` | bigint | monotonic index by paid draw count |
| `target_rtp_ppm` | int | immutable target for this window |
| `control_window_draws` | bigint | expected paid draws in this window |
| `paid_draws` | bigint | paid draws already assigned to this window |
| `wager_coins` | bigint | accumulated cost in this window |
| `target_payout_coins` | bigint | integer payout target for this window |
| `actual_base_payout_coins` | bigint | base RTP rewards selected in this window |
| `carry_ppm` | bigint | fractional target remainder carried into next window |
| `status` | varchar | open, closed, underpaid |
| `updated_at_ms` | bigint | update time |

The primary key is `(scope_type, scope_id, window_index)`.

### `lucky_pools`

保存三层奖池水位。

| Column | Type | Notes |
| --- | --- | --- |
| `scope_type` | varchar | platform, room, gift |
| `scope_id` | varchar | global, room_id, gift_id |
| `balance` | bigint | available coins |
| `reserve_floor` | bigint | minimum safe waterline |
| `total_in` | bigint | historical inflow |
| `total_out` | bigint | historical payout |
| `version` | bigint | optimistic version |
| `updated_at_ms` | bigint | update time |

The primary key is `(scope_type, scope_id)`.

### `lucky_room_atmosphere_pools`

保存房间气氛预算。它控制房间爆点和共享奖励，不拥有 Room Cell 核心状态。

| Column | Type | Notes |
| --- | --- | --- |
| `room_id` | varchar | room id |
| `balance` | bigint | available room atmosphere coins |
| `reserve_floor` | bigint | minimum balance before shared rewards stop |
| `lucky_heat` | bigint | non-coin room atmosphere score |
| `burst_level` | int | current presentation intensity |
| `hourly_shared_payout_limit` | bigint | hourly shared reward cap |
| `daily_shared_payout_limit` | bigint | daily shared reward cap |
| `version` | bigint | optimistic version |
| `updated_at_ms` | bigint | update time |

The primary key is `room_id`.

### `lucky_activity_budgets`

保存活动补贴预算。活动补贴提高用户看到的 `effective_rtp`，但不计入基础 RTP。

| Column | Type | Notes |
| --- | --- | --- |
| `budget_id` | varchar | primary key |
| `activity_id` | varchar | activity id |
| `gift_id` | varchar | optional gift scope, empty means all lucky gifts |
| `budget_coins` | bigint | total subsidy budget |
| `spent_coins` | bigint | granted subsidy coins |
| `daily_spend_limit` | bigint | daily subsidy cap |
| `status` | varchar | draft, active, paused, exhausted, retired |
| `effective_from_ms` | bigint | UTC start time |
| `effective_to_ms` | bigint | UTC end time |
| `updated_at_ms` | bigint | update time |

### `lucky_draw_records`

保存每次抽奖事实。

| Column | Type | Notes |
| --- | --- | --- |
| `draw_id` | varchar | primary key |
| `command_id` | varchar | unique idempotency key |
| `billing_receipt_id` | varchar | wallet debit receipt |
| `room_id` | varchar | room id |
| `sender_user_id` | bigint | payer |
| `target_user_id` | bigint | gift receiver |
| `gift_id` | varchar | gift id |
| `gift_count` | int | count |
| `coin_spent` | bigint | paid coins |
| `heat_value` | bigint | room heat contributed by this gift |
| `rule_version` | varchar | immutable rule version |
| `experience_pool` | varchar | novice, intermediate, advanced |
| `rtp_scope_type` | varchar | scope that controlled this draw |
| `rtp_scope_id` | varchar | concrete scope id |
| `rtp_window_index` | bigint | RTP control window |
| `gift_rtp_window_index` | bigint | gift-level RTP sub-window |
| `candidate_tiers_json` | json | tiers after filtering |
| `selected_tier_id` | varchar | final selected tier |
| `base_reward_coins` | bigint | reward counted in global and gift RTP |
| `room_atmosphere_reward_coins` | bigint | shared or room-burst reward outside base RTP |
| `activity_subsidy_coins` | bigint | activity subsidy outside base RTP |
| `effective_reward_coins` | bigint | total user-visible coin reward |
| `budget_sources_json` | json | exact budget sources consumed by this draw |
| `random_digest` | varchar | audit digest |
| `pool_snapshot_json` | json | balances before draw |
| `rtp_snapshot_json` | json | global and gift RTP window snapshots |
| `budget_snapshot_json` | json | room atmosphere and activity budget snapshots |
| `risk_snapshot_json` | json | counters before draw |
| `reward_status` | varchar | none, pending, granted, failed |
| `credit_receipt_id` | varchar | wallet credit receipt |
| `created_at_ms` | bigint | draw time |
| `updated_at_ms` | bigint | update time |

### `lucky_risk_counters`

保存可审计的风控计数。Redis 可以做热计数，MySQL 必须能重建关键窗口。

| Column | Type | Notes |
| --- | --- | --- |
| `counter_key` | varchar | primary key |
| `scope_type` | varchar | user, device, room, anchor, activity |
| `scope_id` | varchar | concrete id |
| `window_type` | varchar | hourly, daily, period |
| `payout_coins` | bigint | reward total |
| `draw_count` | bigint | draw total |
| `hit_count` | bigint | hit total |
| `window_start_ms` | bigint | window start |
| `window_end_ms` | bigint | window end |

### `lucky_outbox`

保存需要补偿的外部动作。

| Column | Type | Notes |
| --- | --- | --- |
| `event_id` | varchar | primary key |
| `event_type` | varchar | reward_credit, room_event, activity_event |
| `aggregate_id` | varchar | draw_id |
| `payload` | blob | protobuf bytes |
| `status` | varchar | pending, processing, done, failed |
| `retry_count` | int | retry count |
| `next_retry_at_ms` | bigint | backoff time |
| `created_at_ms` | bigint | creation time |
| `updated_at_ms` | bigint | update time |

## Draw Algorithm

All money-like values use integer coins. Percentages use ppm to avoid floating point drift. V2 separates base RTP payout from entertainment subsidies.

### 1. Experience Pool

```text
paid_draw_number = user_lucky_paid_draw_count + 1
if paid_draw_number <= novice_draw_limit:
  experience_pool = novice
else if paid_draw_number <= intermediate_draw_limit:
  experience_pool = intermediate
else:
  experience_pool = advanced
```

Stage feedback is calculated from the same paid draw number. It is a presentation state unless the selected candidate explicitly carries `base_rtp` or `activity_subsidy` budget.

```text
novice_feedback_marks = [1, novice_draw_limit * 25%, novice_draw_limit * 50%, novice_draw_limit * 75%, novice_draw_limit]
stage_feedback_enabled = paid_draw_number in novice_feedback_marks
```

Feedback cannot credit coins after selection. If a milestone should pay coins, that payout must be present in the pre-random candidate set and must pass all RTP, pool, and risk filters.

### 2. Pool Inflow And Capacity

```text
pool_in = floor(coin_spent * pool_rate_ppm / 1000000)
platform_in = floor(pool_in * platform_weight_ppm / 1000000)
room_in = floor(pool_in * room_weight_ppm / 1000000)
gift_in = pool_in - platform_in - room_in

platform_capacity = platform_balance + platform_in - platform_reserve_floor
room_capacity = room_balance + room_in - room_reserve_floor
gift_capacity = gift_balance + gift_in - gift_reserve_floor
```

Risk remains a hard cap:

```text
risk_capacity = min(
  max_single_payout,
  user_daily_remaining,
  device_daily_remaining,
  room_hourly_remaining,
  anchor_daily_remaining,
  activity_period_remaining
)
```

Time-window caps must be configured above the expected payout for that window. Otherwise the transaction can satisfy the user risk cap or the RTP target, but not both.

```text
expected_window_payout = paid_draw_capacity
                       * gift_price
                       * target_rtp_ppm
                       / 1000000

hourly_cap >= expected_hourly_payout * volatility_factor
daily_cap >= expected_daily_payout * volatility_factor
```

Use a larger `volatility_factor` for higher variance pools. The default demo model uses `2.0` for hourly and `1.5` for daily caps.

### 3. Global And Gift RTP Windows

Every draw enters two RTP windows:

```text
global_window_target = floor((global_window_wager * global_target_rtp_ppm + global_carry_ppm) / 1000000)
gift_window_target = floor((gift_window_wager * gift_target_rtp_ppm + gift_carry_ppm) / 1000000)

global_remaining_payout = global_window_target - global_window_base_payout
gift_remaining_payout = gift_window_target - gift_window_base_payout

global_remaining_draws = global_window_draws - global_window_paid_draws
gift_remaining_draws = gift_window_draws - gift_window_paid_draws
```

High multiplier tiers are disabled until both RTP windows have positive payout headroom large enough to pay the tier:

```text
rtp_headroom = remaining_payout
             - remaining_draws * gift_price * target_rtp_ppm / 1000000

high_multiplier_enabled = global_rtp_headroom >= tier_reward
                       && gift_rtp_headroom >= tier_reward
```

The full production filter must additionally require platform, room, and gift pool headroom above the same tier reward plus reserve floors.

The base payout capacity is the strict minimum:

```text
base_capacity = min(
  platform_capacity,
  room_capacity,
  gift_capacity,
  risk_capacity,
  global_remaining_payout,
  gift_remaining_payout
)
```

### 4. Candidate Construction

Build candidates from three independent sources:

| Source | Candidate Fields | Accounting |
| --- | --- | --- |
| `base_rtp` | tier id, base reward, experience pool weight | counts in global and gift RTP windows |
| `room_atmosphere` | room burst, shared reward, presentation | consumes room atmosphere budget only |
| `activity_subsidy` | subsidy reward, activity id, display tag | consumes activity budget only |

Base candidates must preserve both RTP windows:

```text
base_reward_coins <= base_capacity

global_remaining_payout - base_reward_coins
  <= global_min_future_max_reward * (global_remaining_draws - 1)

gift_remaining_payout - base_reward_coins
  <= gift_min_future_max_reward * (gift_remaining_draws - 1)
```

Room atmosphere and activity candidates cannot be used to satisfy these formulas. They only increase `effective_reward_coins`.

### 5. Selection

```text
candidate_set = filtered_base_candidates
              + filtered_room_atmosphere_candidates
              + filtered_activity_subsidy_candidates

selected = weighted_random(candidate_set, secure_random)
```

Selection rules:

| Rule | Behavior |
| --- | --- |
| base reward exceeds base capacity | base tier disabled |
| base reward breaks either RTP window feasibility | base tier disabled |
| high multiplier lacks global, gift, or pool headroom | high tier disabled and its weight moves to `none` |
| room atmosphere reward exceeds room atmosphere budget | room candidate disabled |
| activity subsidy exceeds active budget or daily limit | activity candidate disabled |
| risk cap would be exceeded | coin-paying candidate disabled |
| disabled tier weight | moved to `none` only inside the same source |

The final random selection runs only on the filtered table. It must never select a tier that cannot be paid.

### 6. Accounting

```text
base_reward_coins = selected.base_reward_coins
room_atmosphere_reward_coins = selected.room_atmosphere_reward_coins
activity_subsidy_coins = selected.activity_subsidy_coins

effective_reward_coins = base_reward_coins
                       + room_atmosphere_reward_coins
                       + activity_subsidy_coins
```

Only `base_reward_coins` updates global and gift RTP windows. Room atmosphere and activity subsidies update their own budgets and are reported separately in metrics.

## Transaction Boundary

`ExecuteLuckyGiftDraw` uses one local MySQL transaction for the draw truth.

```text
1. read idempotency by command_id
2. lock lucky_gift_rules active version
3. lock global lucky_rtp_windows row
4. lock gift lucky_rtp_windows row
5. lock platform, room, gift pool rows with SELECT FOR UPDATE
6. lock lucky_room_atmosphere_pools row
7. lock active lucky_activity_budgets row when configured
8. load or lock risk counters for current windows
9. choose novice/intermediate/advanced experience pool
10. calculate stage feedback state from the user's paid draw number
11. calculate base RTP capacity, room atmosphere capacity, and activity subsidy capacity
12. calculate single, hourly, daily, device, room, anchor, and activity risk remaining
13. calculate high multiplier headroom from RTP windows and three-layer pools
14. build and filter base, room atmosphere, and activity subsidy candidates
15. execute weighted random selection once
16. update platform, room, gift pool balances for base reward only
17. update global and gift RTP windows for base_reward_coins only
18. update room atmosphere and activity budget spending separately
19. update risk counters for all coin-paying rewards
20. insert lucky_draw_records with separated reward fields and snapshots
21. insert lucky_outbox if effective_reward_coins > 0 or room presentation is required
22. commit
```

After commit, `activity-service` may synchronously dispatch the wallet credit for low latency. If dispatch fails, it must keep the outbox pending and retry.

Do not call `wallet-service` while holding MySQL pool locks. External RPC inside the pool transaction can block all draws for the same room or gift.

## Idempotency

`command_id` is the global idempotency key.

| Case | Required Behavior |
| --- | --- |
| duplicate `CheckLuckyGift` | return current enabled rule |
| duplicate `ExecuteLuckyGiftDraw` before commit | one succeeds, one waits or fails retryably |
| duplicate `ExecuteLuckyGiftDraw` after commit | return stored draw result |
| duplicate wallet credit | wallet returns same `credit_receipt_id` |
| duplicate outbox dispatch | consumer ignores completed `event_id` |

`draw_id` should be derived from `command_id` or stored under a unique `command_id` constraint.

## Randomness and Audit

V2 should use server-side cryptographically secure randomness. Every draw record must keep enough audit material to prove the result came from the recorded candidate table.

Recommended audit fields:

| Field | Meaning |
| --- | --- |
| `random_digest` | HMAC or hash of random source and draw context |
| `candidate_tiers_json` | exact filtered probability table |
| `pool_snapshot_json` | pool balances before selection |
| `risk_snapshot_json` | caps and remaining quota |
| `rule_version` | immutable probability version |

如果后续需要更强公信力，可以升级为 daily seed commit-reveal：每天提前公布 `hash(seed)`，开奖后公布 `seed`，用户可复算结果。

## Failure Handling

| Failure | Behavior |
| --- | --- |
| `CheckLuckyGift` unavailable before debit | reject lucky gift send, do not debit |
| `DebitGift` failed | do not call draw |
| `ExecuteLuckyGiftDraw` timeout after debit | room gift can land as pending lucky draw; retry by `command_id` |
| draw transaction committed but wallet credit failed | keep `reward_status=PENDING`, retry outbox |
| wallet credit succeeded but response lost | retry credit by `reward_id`, wallet returns same receipt |
| im broadcast failed | room-service outbox handles room event compensation |
| activity outbox exhausted retries | mark failed and alert; do not delete record |

The product response must distinguish `GRANTED` and `PENDING`. Only `GRANTED` can show “金币已到账”。

## Service Modules

Recommended `activity-service` layout:

```text
services/activity-service/
  internal/
    luckygift/
      service/
        service.go
        draw.go
        risk.go
        idempotency.go
      rule/
        repository.go
        validator.go
      pool/
        repository.go
        allocator.go
      rtp/
        window.go
        controller.go
      atmosphere/
        room_pool.go
        presenter.go
      budget/
        activity_budget.go
      reward/
        wallet_client.go
        dispatcher.go
      outbox/
        store.go
        worker.go
      audit/
        random.go
        digest.go
    storage/
      mysql/
      redis/
    transport/
      grpc/
```

Keep the draw logic in one service package. Do not split probability, pool, and risk into separate network services in V1.

## Integration Events

`activity-service` should emit protobuf events for downstream statistics and audit.

Suggested event types:

| Event | Payload |
| --- | --- |
| `LuckyGiftDrawn` | draw id, gift id, rule version, tier, reward coins |
| `LuckyRewardGranted` | reward id, draw id, user id, credit receipt |
| `LuckyRewardPending` | reward id, draw id, retry reason |
| `LuckyPoolChanged` | scope, delta in, delta out, balance |
| `LuckyRiskRejected` | scope, threshold, reason |

These events should live under `api/proto/events/activity/v1` when implementation starts.

## Config

`services/activity-service/configs/config.yaml` should eventually include:

```yaml
grpc_addr: ":13006"
mysql_dsn: "root:root@tcp(mysql:3306)/hyapp_activity?parseTime=true"
redis_addr: "redis:6379"
redis_password: ""
wallet_grpc_addr: "wallet-service:13004"
outbox_batch_size: 100
outbox_poll_interval: "500ms"
draw_timeout: "300ms"
wallet_credit_timeout: "500ms"
```

Local Docker uses MySQL and Redis from `docker-compose.yml`; production uses Tencent Cloud Redis and TencentDB for MySQL with the same config keys.

## Testing

Required tests:

| Test | Assertion |
| --- | --- |
| experience pool validation | novice, intermediate, advanced tier weights are positive and usable |
| RTP window validation | `100000` paid draws deviate from target RTP by `<1%` |
| RTP million validation | `1000000` paid draws deviate from target RTP by `<0.5%` |
| gift RTP sub-window validation | one gift cannot overpay while global RTP is still healthy |
| subsidy isolation | room atmosphere and activity subsidy do not update base RTP windows |
| low-water filtering | unavailable tier weight moves to `none` |
| pool transaction | platform, room, gift balances update atomically |
| RTP transaction | concurrent draws cannot double-spend the same window remaining payout |
| room atmosphere budget | shared rewards stop when room atmosphere budget reaches floor |
| activity budget exhaustion | subsidy candidates are disabled after budget or daily cap is exhausted |
| user daily cap | reward tier over cap is filtered before draw |
| idempotent draw | duplicate `command_id` returns same result |
| wallet credit retry | pending reward eventually becomes granted |
| draw timeout retry | no duplicate pool payout |
| concurrent room draws | row locks prevent negative pool balance |
| audit replay | stored candidate table reproduces selected tier check |

Load tests should verify the hottest room path because room pool row locks can become the first bottleneck.

Production simulation must cover the full online control surface before service implementation starts:

```bash
go run ./tools/lucky-gift-v2-demo \
  -users 1000 \
  -devices 900 \
  -rooms 50 \
  -draws 100000 \
  -base-rtp 95 \
  -initial 1000000000 \
  -cost 500 \
  -global-window 100000 \
  -gift-window 100000
```

The expected pass condition is:

| Check | Required Result |
| --- | --- |
| `base_rtp` | remains within the configured global and gift RTP windows |
| `effective_rtp` | may be higher than `base_rtp` only through room atmosphere or activity subsidy |
| platform, room, gift pools | no pool pays below its reserve floor |
| user, device, room, anchor caps | all coin-paying candidates are filtered before selection |
| high multiplier | disabled unless RTP windows and three-layer pools have enough high-water headroom |

## Rollout

V1 rollout order:

| Step | Scope |
| --- | --- |
| 1 | Add proto and DB schema behind disabled config |
| 2 | Implement `CheckLuckyGift` and rule validation |
| 3 | Implement pool transaction and draw records with reward disabled |
| 4 | Add wallet `CreditLuckyReward` and pending reward outbox |
| 5 | Connect `room-service SendGift` for selected lucky gifts |
| 6 | Enable one low-price gift in internal room |
| 7 | Enable room-level metrics and alerting |
| 8 | Expand to higher-price gifts only after payout rate is stable |

Operational kill switches:

| Switch | Effect |
| --- | --- |
| `global_lucky_gift_enabled=false` | all lucky gifts reject before debit |
| `gift_rule.status=paused` | single gift stops lucky draw |
| `large_tier_enabled=false` | disables high-risk tiers |
| `wallet_credit_dispatch=false` | rewards stay pending; used only during wallet incident |
| `room_scope_paused` | disables lucky draw for one room |
