validation.gno
6.14 Kb · 250 lines
1package craft
2
3import (
4 "net/url"
5 "strconv"
6)
7
8const (
9 recipeBatchLimit = 100
10 maxRecipeIngredients = 16
11 defaultRecipeListLimit = 100
12 defaultCraftFee int64 = 1_000_000
13)
14
15type craftIngredient struct {
16 Slot uint
17 TokenID string
18 Amount int64
19}
20
21func mustParseRecipeCSV(recipesCSV, itemIDsCSV string) ([]string, []uint32) {
22 if recipesCSV == "" {
23 panic("recipes is required")
24 }
25 if itemIDsCSV == "" {
26 panic("item ids is required")
27 }
28
29 canonicalRecipes := []string{}
30 itemIDs := []uint32{}
31 recipeStart, recipeEnd := 0, 0
32 itemIDStart, itemIDEnd := 0, 0
33 for {
34 for recipeEnd < len(recipesCSV) && recipesCSV[recipeEnd] != ',' {
35 recipeEnd++
36 }
37 for itemIDEnd < len(itemIDsCSV) && itemIDsCSV[itemIDEnd] != ',' {
38 itemIDEnd++
39 }
40
41 recipeToken := recipesCSV[recipeStart:recipeEnd]
42 itemIDText := itemIDsCSV[itemIDStart:itemIDEnd]
43 if recipeToken == "" {
44 panic("empty recipe not allowed")
45 }
46 if itemIDText == "" {
47 panic("empty item id not allowed")
48 }
49
50 canonicalRecipe, _ := mustParseRecipe(recipeToken)
51 itemIDValue, err := strconv.ParseUint(itemIDText, 10, 32)
52 if err != nil {
53 panic("invalid item id: " + itemIDText)
54 }
55
56 canonicalRecipes = append(canonicalRecipes, canonicalRecipe)
57 itemIDs = append(itemIDs, uint32(itemIDValue))
58 if len(canonicalRecipes) > recipeBatchLimit {
59 panic("recipes exceeds 100")
60 }
61
62 recipeDone := recipeEnd >= len(recipesCSV)
63 itemIDDone := itemIDEnd >= len(itemIDsCSV)
64 if recipeDone != itemIDDone {
65 panic("recipes and item ids count mismatch")
66 }
67 if recipeDone {
68 return canonicalRecipes, itemIDs
69 }
70
71 recipeEnd++
72 itemIDEnd++
73 recipeStart = recipeEnd
74 itemIDStart = itemIDEnd
75 }
76}
77
78func mustParseRecipeKeyCSV(recipesCSV string) []string {
79 if recipesCSV == "" {
80 panic("recipes is required")
81 }
82
83 canonicalRecipes := []string{}
84 recipeStart, recipeEnd := 0, 0
85 for {
86 for recipeEnd < len(recipesCSV) && recipesCSV[recipeEnd] != ',' {
87 recipeEnd++
88 }
89
90 recipeToken := recipesCSV[recipeStart:recipeEnd]
91 if recipeToken == "" {
92 panic("empty recipe not allowed")
93 }
94
95 canonicalRecipe, _ := mustParseRecipe(recipeToken)
96 canonicalRecipes = append(canonicalRecipes, canonicalRecipe)
97 if len(canonicalRecipes) > recipeBatchLimit {
98 panic("recipes exceeds 100")
99 }
100
101 if recipeEnd >= len(recipesCSV) {
102 return canonicalRecipes
103 }
104
105 recipeEnd++
106 recipeStart = recipeEnd
107 }
108}
109
110func mustParseRecipe(recipe string) (string, []craftIngredient) {
111 canonical, ingredients, errMsg := parseRecipe(recipe)
112 if errMsg != "" {
113 panic(errMsg)
114 }
115 return canonical, ingredients
116}
117
118// A valid recipe is "<4-hex occupancy>|<ingredient>|...". Occupancy bits
119// choose slots 0-15 in order; each ingredient is "tokenId:amount" or
120// "slot:tokenId:amount". Example: "0013|iron:1|iron:2|stick:2" (bits 0,1,4)
121// canonicalizes to "0013|0:iron:1|1:iron:2|4:stick:2".
122func parseRecipe(recipe string) (string, []craftIngredient, string) {
123 if len(recipe) < 7 || recipe[4] != '|' {
124 return "", nil, "invalid recipe"
125 }
126
127 occupancyValue, err := strconv.ParseUint(recipe[:4], 16, 16)
128 if err != nil || occupancyValue == 0 {
129 return "", nil, "invalid recipe occupancy"
130 }
131 occupancy := uint16(occupancyValue)
132 slots := occupiedSlots(occupancy)
133
134 ingredients := []craftIngredient{}
135 entryStart, entryEnd := 5, 5
136 for {
137 for entryEnd < len(recipe) && recipe[entryEnd] != '|' {
138 entryEnd++
139 }
140
141 if len(ingredients) == len(slots) {
142 return "", nil, "occupied slots and ingredients count mismatch"
143 }
144
145 ingredient, errMsg := parseRecipeIngredient(
146 recipe[entryStart:entryEnd],
147 slots[len(ingredients)],
148 )
149 if errMsg != "" {
150 return "", nil, errMsg
151 }
152 ingredients = append(ingredients, ingredient)
153 if len(ingredients) > maxRecipeIngredients {
154 return "", nil, "recipe ingredients exceeds 16"
155 }
156
157 if entryEnd >= len(recipe) {
158 break
159 }
160
161 entryEnd++
162 entryStart = entryEnd
163 }
164
165 if len(ingredients) != len(slots) {
166 return "", nil, "occupied slots and ingredients count mismatch"
167 }
168
169 canonicalRecipe := formatOccupancy(occupancy)
170 for _, ingredient := range ingredients {
171 canonicalRecipe += "|" +
172 strconv.FormatUint(uint64(ingredient.Slot), 10) + ":" +
173 url.PathEscape(ingredient.TokenID) + ":" +
174 strconv.FormatInt(ingredient.Amount, 10)
175 }
176 return canonicalRecipe, ingredients, ""
177}
178
179func parseRecipeIngredient(entry string, expectedSlot uint) (craftIngredient, string) {
180 amountSeparator := len(entry) - 1
181 for amountSeparator > 0 && entry[amountSeparator] != ':' {
182 amountSeparator--
183 }
184 if amountSeparator == 0 || entry[amountSeparator] != ':' {
185 return craftIngredient{}, "invalid recipe ingredient"
186 }
187
188 tokenField := entry[:amountSeparator]
189 amountText := entry[amountSeparator+1:]
190 encodedTokenID := tokenField
191 slotPrefixEnd := 0
192 for slotPrefixEnd < len(tokenField) && tokenField[slotPrefixEnd] != ':' {
193 slotPrefixEnd++
194 }
195 if slotPrefixEnd < len(tokenField) && tokenField[slotPrefixEnd] == ':' {
196 slotValue, err := strconv.ParseUint(tokenField[:slotPrefixEnd], 10, 8)
197 if err == nil {
198 if uint(slotValue) != expectedSlot {
199 return craftIngredient{}, "ingredient slot mismatch"
200 }
201 encodedTokenID = tokenField[slotPrefixEnd+1:]
202 }
203 }
204
205 tokenID, err := url.PathUnescape(encodedTokenID)
206 if err != nil || tokenID == "" {
207 return craftIngredient{}, "invalid item token id"
208 }
209
210 amount, err := strconv.ParseInt(amountText, 10, 64)
211 if err != nil {
212 return craftIngredient{}, "invalid ingredient amount: " + amountText
213 }
214 if amount <= 0 {
215 return craftIngredient{}, "ingredient amount must be positive"
216 }
217
218 return craftIngredient{
219 Slot: expectedSlot,
220 TokenID: tokenID,
221 Amount: amount,
222 }, ""
223}
224
225func occupiedSlots(occupancy uint16) []uint {
226 slots := []uint{}
227 for slot := uint(0); slot < 16; slot++ {
228 if occupancy&(uint16(1)<<slot) != 0 {
229 slots = append(slots, slot)
230 }
231 }
232 return slots
233}
234
235func formatOccupancy(occupancy uint16) string {
236 value := strconv.FormatUint(uint64(occupancy), 16)
237 return "0000"[:4-len(value)] + value
238}
239
240func assertRecipeListPageCount(page, count int) {
241 if page < 1 {
242 panic("page must be at least 1")
243 }
244 if count < 1 {
245 panic("count must be at least 1")
246 }
247 if count > listLimit {
248 panic("count exceeds listLimit (max: " + strconv.Itoa(listLimit) + ")")
249 }
250}