diff --git a/cmd/themis-server/main.go b/cmd/themis-server/main.go index 7c8722f..06bd6d5 100644 --- a/cmd/themis-server/main.go +++ b/cmd/themis-server/main.go @@ -90,11 +90,17 @@ func main() { Name: "claim-type", Description: "one of `area`, `region` or `trade`", Type: discordgo.ApplicationCommandOptionString, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + {Name: "Area", Value: themis.CLAIM_TYPE_AREA}, + {Name: "Region", Value: themis.CLAIM_TYPE_REGION}, + {Name: "Trade Node", Value: themis.CLAIM_TYPE_TRADE}, + }, }, { - Name: "name", - Description: "the name of zone claimed", - Type: discordgo.ApplicationCommandOptionString, + Name: "name", + Description: "the name of zone claimed", + Type: discordgo.ApplicationCommandOptionString, + Autocomplete: true, }, }, }, @@ -142,6 +148,11 @@ func main() { } }, "claim": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.Type == discordgo.InteractionApplicationCommandAutocomplete { + handleClaimAutocomplete(ctx, store, s, i) + return + } + opts := i.ApplicationCommandData().Options claimType, err := themis.ClaimTypeFromString(opts[0].StringValue()) if err != nil { @@ -306,6 +317,38 @@ func formatClaimsTable(claims []themis.Claim) string { return sb.String() } +func handleClaimAutocomplete(ctx context.Context, store *themis.Store, s *discordgo.Session, i *discordgo.InteractionCreate) { + opts := i.ApplicationCommandData().Options + claimType, err := themis.ClaimTypeFromString(opts[0].StringValue()) + if err != nil { + log.Printf("[error]: %s\n", err) + return + } + + availability, err := store.ListAvailability(ctx, claimType, opts[1].StringValue()) + if err != nil { + log.Printf("[error]: %s\n", err) + return + } + + choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(availability)) + for _, s := range availability { + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: s, + Value: s, + }) + } + + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{ + Choices: choices[:min(len(choices), 25)], + }, + }); err != nil { + log.Printf("[error]: %s\n", err) + } +} + func serve(address string) error { http.Handle("/health", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("OK")) @@ -313,3 +356,10 @@ func serve(address string) error { return http.ListenAndServe(address, nil) } + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/migrations/init.sql b/migrations/init.sql index ebc3d1d..bd8cd9f 100644 --- a/migrations/init.sql +++ b/migrations/init.sql @@ -3940,7 +3940,7 @@ INSERT OR IGNORE INTO provinces VALUES('4937','Lau','3','1','1','1','','Polynesi INSERT OR IGNORE INTO provinces VALUES('4938','Vanua Levu','5','2','2','1','','Polynesian Triangle','','Land','Oceania','Oceania','Oceania','Fiji'); INSERT OR IGNORE INTO provinces VALUES('4939','Te Urewera','8','3','4','1','','Polynesian Triangle','','Land','Oceania','Oceania','Oceania','Te Ika a Maui Waho'); INSERT OR IGNORE INTO provinces VALUES('4940','Lake Tulare','','','','','','','','Lake','','','',''); -INSERT OR IGNORE INTO provinces VALUES('4941','Lake Cahuilla','','','','','','','','Lake','','','',NULL); +INSERT OR IGNORE INTO provinces VALUES('4941','Lake Cahuilla','','','','','','','','Lake','','','',''); CREATE TABLE IF NOT EXISTS claim_types ( claim_type TEXT PRIMARY KEY diff --git a/store.go b/store.go index 8753a1a..a930a89 100644 --- a/store.go +++ b/store.go @@ -144,6 +144,40 @@ func (s *Store) Claim(ctx context.Context, player, province string, claimType Cl return nil } +func (s *Store) ListAvailability(ctx context.Context, claimType ClaimType, search ...string) ([]string, error) { + queryParams := []any{string(claimType)} + queryPattern := `SELECT DISTINCT(provinces.%[1]s) + FROM provinces LEFT JOIN claims ON provinces.%[1]s = claims.val AND claims.claim_type = ? + WHERE claims.val IS NULL + AND provinces.typ = 'Land'` + if len(search) > 0 && search[0] != "" { + // only take one search param, ignore the rest + queryPattern += `AND provinces.%[1]s LIKE ?` + queryParams = append(queryParams, fmt.Sprintf("%%%s%%", search[0])) + } + + stmt, err := s.db.PrepareContext(ctx, fmt.Sprintf(queryPattern, claimTypeToColumn[claimType])) + if err != nil { + return nil, fmt.Errorf("failed to prepare query: %w", err) + } + + rows, err := stmt.QueryContext(ctx, queryParams...) + if err != nil { + return nil, fmt.Errorf("failed to execute query: %w", err) + } + + avail := make([]string, 0) + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return nil, fmt.Errorf("failed to scan rows: %w", err) + } + avail = append(avail, s) + } + + return avail, nil +} + func (s *Store) ListClaims(ctx context.Context) ([]Claim, error) { stmt, err := s.db.PrepareContext(ctx, `SELECT id, player, claim_type, val FROM claims`) if err != nil { diff --git a/store_test.go b/store_test.go index 8fe7666..7e89ba6 100644 --- a/store_test.go +++ b/store_test.go @@ -68,3 +68,50 @@ func TestStore_Claim(t *testing.T) { }) } } + +func TestAvailability(t *testing.T) { + store, err := NewStore("file::memory:?cache=shared") + assert.NoError(t, err) + + store.Claim(context.TODO(), "foo", "Genoa", CLAIM_TYPE_TRADE) + store.Claim(context.TODO(), "foo", "Venice", CLAIM_TYPE_TRADE) + store.Claim(context.TODO(), "foo", "English Channel", CLAIM_TYPE_TRADE) + + // There's a total of 80 distinct trade nodes, there should be 77 available + // after the three claims above + availability, err := store.ListAvailability(context.TODO(), CLAIM_TYPE_TRADE) + assert.NoError(t, err) + assert.Equal(t, 77, len(availability)) + + store.Claim(context.TODO(), "foo", "France", CLAIM_TYPE_REGION) + store.Claim(context.TODO(), "foo", "Italy", CLAIM_TYPE_REGION) + + // There's a total of 73 distinct regions, there should be 71 available + // after the two claims above + availability, err = store.ListAvailability(context.TODO(), CLAIM_TYPE_REGION) + assert.NoError(t, err) + assert.Equal(t, 71, len(availability)) + + store.Claim(context.TODO(), "foo", "Normandy", CLAIM_TYPE_AREA) + store.Claim(context.TODO(), "foo", "Champagne", CLAIM_TYPE_AREA) + store.Claim(context.TODO(), "foo", "Lorraine", CLAIM_TYPE_AREA) + store.Claim(context.TODO(), "foo", "Provence", CLAIM_TYPE_AREA) + + // There's a total of 823 distinct regions, there should be 819 available + // after the four claims above + availability, err = store.ListAvailability(context.TODO(), CLAIM_TYPE_AREA) + assert.NoError(t, err) + assert.Equal(t, 819, len(availability)) + + // There is both a Trade Node and an Area called 'Valencia', while the trade + // node is claimed, the area should show up in the availability list (even + // though there are conflicting provinces) + store.Claim(context.TODO(), "foo", "Valencia", CLAIM_TYPE_TRADE) + availability, err = store.ListAvailability(context.TODO(), CLAIM_TYPE_AREA) + assert.NoError(t, err) + assert.Equal(t, 819, len(availability)) // availability for areas should be the same as before + + availability, err = store.ListAvailability(context.TODO(), CLAIM_TYPE_AREA, "bay") + assert.NoError(t, err) + assert.Equal(t, 3, len(availability)) // availability for areas should be the same as before +}