Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
eljamo committed Mar 31, 2024
1 parent 1dc7f93 commit 9710ad1
Show file tree
Hide file tree
Showing 13 changed files with 696 additions and 0 deletions.
11 changes: 11 additions & 0 deletions .github/dependabot.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
70 changes: 70 additions & 0 deletions .github/workflows/codeql-analysis.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"

on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '41 14 * * 2'

jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write

strategy:
fail-fast: false
matrix:
language: [ 'go' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support

steps:
- name: Checkout repository
uses: actions/checkout@v4

# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main

# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3

# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl

# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language

#- run: |
# make bootstrap
# make release

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
14 changes: 14 additions & 0 deletions .github/workflows/coverage.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: coverage
on: push

jobs:
coverage:
runs-on: ubuntu-latest
name: Go test coverage
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "stable"
- run: go test -coverprofile=coverage.txt -covermode=atomic
- uses: codecov/codecov-action@v4
36 changes: 36 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: goreleaser

on:
push:
tags:
- "v*"

permissions:
contents: write

jobs:
test:
uses: ./.github/workflows/test.yaml
secrets: inherit
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
-
name: Setup
uses: actions/setup-go@v5
with:
go-version: '>=1.22'
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser
version: ${{ env.GITHUB_REF_NAME }}
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.WEIGHTEDOPTION_RELEASE_TOKEN }}
30 changes: 30 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: test

on:
push:
branches:
- '*'
tags-ignore:
- '*'
pull_request:
workflow_call:

jobs:
test:
runs-on: ubuntu-latest
name: Tests
steps:
-
name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
-
name: Setup
uses: actions/setup-go@v5
with:
go-version: '>=1.22'

-
name: Test
run: go test --race --shuffle on ./...
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@

# Go workspace file
go.work

# MacOS
.DS_Store

# GoLand
.idea/
16 changes: 16 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
before:
hooks:
- go mod tidy

builds:
- skip: true

release:
prerelease: auto

changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,43 @@
# weightedoption

A Go package for weighted random option selection

## Example Usage

```go
package main

import (
"fmt"
"log"

"github.com/eljamo/weightedoption"
)

// Simulates 100 chances for dropping a raid exotic weapon from a Destiny which has a 5% drop chance when a player completes the raid
func main() {
s, err := weightedoption.NewSelector(
weightedoption.NewOption('🔫', 5),
weightedoption.NewOption('', 95),
)
if err != nil {
log.Fatal(err)
}

chances := make([]rune, 30)
for i := 0; i < len(chances); i++ {
chances[i] = s.Select()
}
fmt.Println(string(chances))

tally := make(map[rune]int)
for _, c := range chances {
tally[c]++
}

_, err = fmt.Printf("\n🔫: %d\t%d\n", tally['🔫'], tally[''])
if err != nil {
log.Fatal(err)
}
}
```
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/eljamo/weightedoption

go 1.22.1
132 changes: 132 additions & 0 deletions weightedoption.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package weightedoption

import (
"errors"
"math"
"math/rand/v2"
"sort"
)

var (
ErrWeightOverflow = errors.New("sum of Option weights exceeds total")
ErrNoValidOptions = errors.New("0 Option(s) with Weight >= 1")
)

// WeightIntegerConstraint is a type constraint for the Weight field of the Option struct.
type WeightIntegerConstraint interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

// Option is a struct that holds a data value and its associated weight.
type Option[DataType any, WeightIntegerType WeightIntegerConstraint] struct {
Data DataType
Weight WeightIntegerType
}

// NewOption creates a new Option.
func NewOption[DataType any, WeightIntegerType WeightIntegerConstraint](
data DataType,
weight WeightIntegerType,
) Option[DataType, WeightIntegerType] {
return Option[DataType, WeightIntegerType]{Data: data, Weight: weight}
}

// SearchIntsFuncSignature is the signature of the function used to search for an integer in a sorted slice of integers.
type SearchIntsFuncSignature func(runningTotalWeights []int, randInt int) int

// Selector is a struct that holds a slice of Options and their cumulative weights.
type Selector[DataType any, WeightIntegerType WeightIntegerConstraint] struct {
options []Option[DataType, WeightIntegerType]
runningTotalWeights []int
totalWeight int
searchIntsFunc SearchIntsFuncSignature
}

// NewSelector creates a new Selector.
func NewSelector[DataType any, WeightIntegerType WeightIntegerConstraint](
options ...Option[DataType, WeightIntegerType],
) (*Selector[DataType, WeightIntegerType], error) {
var filteredOptions []Option[DataType, WeightIntegerType]
for _, opt := range options {
if opt.Weight > 0 {
filteredOptions = append(filteredOptions, opt)
}
}

sort.Slice(filteredOptions, func(i, j int) bool {
return filteredOptions[i].Weight < filteredOptions[j].Weight
})

runningTotalWeights := make([]int, len(filteredOptions))
totalWeight := 0

for i, opt := range filteredOptions {
if uint(opt.Weight) >= math.MaxInt {
return nil, ErrWeightOverflow
}

weight := int(opt.Weight)
if weight > math.MaxInt-totalWeight {
return nil, ErrWeightOverflow
}

totalWeight += weight
runningTotalWeights[i] = totalWeight
}

if totalWeight < 1 {
return nil, ErrNoValidOptions
}

return &Selector[DataType, WeightIntegerType]{
options: filteredOptions,
runningTotalWeights: runningTotalWeights,
totalWeight: totalWeight,
searchIntsFunc: searchInts,
}, nil
}

// NewSelectorWithCustomSearchIntsFunc creates a new Selector with a custom searchIntsFunc.
func NewSelectorWithCustomSearchIntsFunc[DataType any, WeightIntegerType WeightIntegerConstraint](
searchIntsFunc SearchIntsFuncSignature,
options ...Option[DataType, WeightIntegerType],
) (*Selector[DataType, WeightIntegerType], error) {
selector, err := NewSelector(options...)
if err != nil {
return nil, err
}

selector.searchIntsFunc = searchIntsFunc
return selector, nil
}

// NewSelectorUsingSortSearchInts creates a new Selector using the sort.SearchInts function.
func NewSelectorUsingSortSearchInts[DataType any, WeightIntegerType WeightIntegerConstraint](
options ...Option[DataType, WeightIntegerType],
) (*Selector[DataType, WeightIntegerType], error) {
return NewSelectorWithCustomSearchIntsFunc(sort.SearchInts, options...)
}

// Select returns a single option from the Selector.
func (s Selector[DataType, WeightIntegerType]) Select() DataType {
r := rand.IntN(s.totalWeight) + 1
i := s.searchIntsFunc(s.runningTotalWeights, r)
return s.options[i].Data
}

// searchInts searches for the index of the first element in runningTotalWeights
// that is greater than or equal to randInt. The slice must be sorted in
// ascending order.
func searchInts(runningTotalWeights []int, randInt int) int {
start, end := 0, len(runningTotalWeights)
for start < end {
mid := int(uint(start+end) >> 1)
if runningTotalWeights[mid] < randInt {
start = mid + 1
} else {
end = mid
}
}

return start
}
Loading

0 comments on commit 9710ad1

Please sign in to comment.