feat(enakgame): spin as an EnakGame game; remove the old game flow
EnakGame phase 10 of docs/tasks-enakgame.md (EG-1001 to EG-1003). Spin (EG-1001) - PROBABILITY entries take an optional label (a wheel segment). The customer game list shows a PROBABILITY game's prizes (entry, label, amount, never weights), and completing returns the drawn prize, so the client can draw the wheel and stop it on the server's draw. - docs/enakgame-spin.md: the admin steps to set up spin per organization (no seeder) and the customer app flow. An HTTP test plays it end to end. Old game flow removed (EG-1002) - Routes POST /customer/spin, GET /customer/games, GET /customer/ferris-wheel, and admin /marketing/games, /marketing/game-prizes, /marketing/rewards, with their handlers, services, processors, repositories, validators, models, contracts, mappers and tests (GamePlayProcessor, SpinGameService, rewards, ...). This also closes RFC §15 findings 1 and 2 (double charge, spinning another org's game). - Tables games, game_prizes, game_plays and rewards stay for ledger history. entities.StringSlice moves to its own file; the omset tracker (unrouted) keeps game_id but no longer embeds the old game response. games.is_active dropped (EG-1003) - Migration 000115; nothing reads metadata.coin_cost any more. The EnakPoint integration docs now point at /customer/enakgame. The Postgres tests were not run: no test database here. Migration 000115 has not been run anywhere. The customer app must stop calling the removed endpoints before this is deployed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
296708244e
commit
18e87398bb
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -332,12 +333,18 @@ func (o outcomeBasedReward) Scale(rules json.RawMessage, multiplier int64) (json
|
||||
|
||||
// PROBABILITY: {"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}.
|
||||
// Weights are whole numbers, so no check depends on floating point (§8). The draw is
|
||||
// kept in the detail for audit.
|
||||
// kept in the detail for audit. An entry may carry a label, such as a spin wheel's
|
||||
// segment ("Zonk", "Jackpot"); the customer sees the entries and the one drawn, never
|
||||
// the weights.
|
||||
type probabilityReward struct{}
|
||||
|
||||
// probabilityLabelLimit bounds a label's length.
|
||||
const probabilityLabelLimit = 100
|
||||
|
||||
type probabilityEntry struct {
|
||||
Weight *int64 `json:"weight"`
|
||||
Amount *int64 `json:"amount"`
|
||||
Weight *int64 `json:"weight"`
|
||||
Amount *int64 `json:"amount"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type probabilityRules struct {
|
||||
@@ -363,6 +370,9 @@ func (probabilityReward) parse(rules json.RawMessage) (probabilityRules, int64,
|
||||
if *e.Amount < 0 {
|
||||
return r, 0, invalidRules("entry %d amount must be 0 or more", i+1)
|
||||
}
|
||||
if e.Label != nil && (strings.TrimSpace(*e.Label) == "" || len(*e.Label) > probabilityLabelLimit) {
|
||||
return r, 0, invalidRules("entry %d label must not be empty, at most %d characters", i+1, probabilityLabelLimit)
|
||||
}
|
||||
total += *e.Weight
|
||||
if total > rewardWeightLimit {
|
||||
return r, 0, invalidRules("the weights add up to more than %d", rewardWeightLimit)
|
||||
@@ -389,9 +399,13 @@ func (p probabilityReward) Calculate(rules json.RawMessage, _ SessionResult, rng
|
||||
for i, e := range r.Table {
|
||||
cumulative += *e.Weight
|
||||
if roll < cumulative {
|
||||
return *e.Amount, map[string]any{
|
||||
detail := map[string]any{
|
||||
"reward_type": constants.GameRewardTypeProbability, "roll": roll, "total_weight": total, "entry": i + 1,
|
||||
}, nil
|
||||
}
|
||||
if e.Label != nil {
|
||||
detail["label"] = *e.Label
|
||||
}
|
||||
return *e.Amount, detail, nil
|
||||
}
|
||||
}
|
||||
return 0, nil, fmt.Errorf("draw %d is outside the total weight %d", roll, total)
|
||||
@@ -410,3 +424,40 @@ func (p probabilityReward) Scale(rules json.RawMessage, multiplier int64) (json.
|
||||
}
|
||||
return marshalRules(r)
|
||||
}
|
||||
|
||||
// probabilityPrizes lists the entries of PROBABILITY rules as the customer sees them:
|
||||
// in order, without their weights.
|
||||
func probabilityPrizes(rules json.RawMessage) ([]models.GamePrize, error) {
|
||||
r, _, err := probabilityReward{}.parse(rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prizes := make([]models.GamePrize, 0, len(r.Table))
|
||||
for i, e := range r.Table {
|
||||
prizes = append(prizes, models.GamePrize{Entry: i + 1, Label: e.Label, Amount: *e.Amount})
|
||||
}
|
||||
return prizes, nil
|
||||
}
|
||||
|
||||
// drawnPrize is the entry a PROBABILITY reward drew, read back from its detail; nil
|
||||
// for another type or when nothing was drawn.
|
||||
func drawnPrize(rewardType string, base int64, detail map[string]any) *models.GamePrize {
|
||||
if rewardType != constants.GameRewardTypeProbability {
|
||||
return nil
|
||||
}
|
||||
// A number read back from JSON is a float64; one just drawn is an int.
|
||||
var entry int
|
||||
switch v := detail["entry"].(type) {
|
||||
case int:
|
||||
entry = v
|
||||
case float64:
|
||||
entry = int(v)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
prize := &models.GamePrize{Entry: entry, Amount: base}
|
||||
if label, ok := detail["label"].(string); ok {
|
||||
prize.Label = &label
|
||||
}
|
||||
return prize
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user