This commit is contained in:
commit
0046177494
41 changed files with 31227 additions and 0 deletions
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Folders
|
||||
_obj
|
||||
_test
|
||||
|
||||
# Architecture specific extensions/prefixes
|
||||
*.[568vq]
|
||||
[568vq].out
|
||||
|
||||
*.cgo1.go
|
||||
*.cgo2.c
|
||||
_cgo_defun.c
|
||||
_cgo_gotypes.go
|
||||
_cgo_export.*
|
||||
|
||||
_testmain.go
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
db
|
||||
20
.travis.yml
Normal file
20
.travis.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
language: go
|
||||
|
||||
install:
|
||||
- go get -d -t -v ./...
|
||||
- go get -v golang.org/x/lint/golint
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- name: "go1.13.x-linux"
|
||||
go: 1.13.x
|
||||
os: linux
|
||||
script: go test ./...
|
||||
- name: "go1.14.x-linux"
|
||||
go: 1.14.x
|
||||
os: linux
|
||||
script: go test ./...
|
||||
- name: "go1.13.x-linux-race"
|
||||
go: 1.13.x
|
||||
os: linux
|
||||
script: go test -race ./...
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Build backend.
|
||||
FROM golang:1.14-alpine as backend
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN apk add gcc musl-dev \
|
||||
&& go build ./cmd/horsepaste/main.go
|
||||
|
||||
# Build frontend.
|
||||
FROM node:12-alpine as frontend
|
||||
COPY . /app
|
||||
WORKDIR /app/frontend
|
||||
RUN npm install -g parcel-bundler \
|
||||
&& npm install \
|
||||
&& sh build.sh
|
||||
|
||||
# Copy build artifacts from previous build stages (to remove files not necessary for
|
||||
# deployment).
|
||||
FROM alpine:3.11
|
||||
WORKDIR /app
|
||||
COPY --from=backend /app/main .
|
||||
COPY --from=frontend /app/frontend/dist ./frontend/dist
|
||||
COPY assets assets
|
||||
EXPOSE 9091/tcp
|
||||
CMD /app/main
|
||||
55
README.md
Normal file
55
README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# horsepaste
|
||||
|
||||
[](https://godoc.org/github.com/jbowens/horsepaste)
|
||||
|
||||
Horsepaste is a word-guessing game. Generated boards are shareable and sync. The board can be viewed either as a clue giver or a word guesser.
|
||||
|
||||
A hosted version of the app is available at [www.horsepaste.com](https://www.horsepaste.com).
|
||||
|
||||

|
||||
|
||||
## Building
|
||||
|
||||
The app requires a [Go](https://golang.org/) toolchain, node.js and [parcel](https://parceljs.org/) to build. Once you have those setup, build the application Go binary with:
|
||||
|
||||
```
|
||||
go install github.com/jbowens/horsepaste/cmd/horsepaste
|
||||
```
|
||||
|
||||
Then from the frontend directory, install the node modules:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
and start the app (listens to changes)
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
or build the app
|
||||
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
Alternatively, the repository includes a Dockerfile for building a docker image of this app.
|
||||
|
||||
```
|
||||
docker build . -t horsepaste:latest
|
||||
```
|
||||
|
||||
The following command will launch the docker image:
|
||||
|
||||
```
|
||||
docker run --name horsepaste_server --rm -p 9091:9091 -d horsepaste
|
||||
```
|
||||
|
||||
The following command will kill the docker instance:
|
||||
|
||||
```
|
||||
docker stop horsepaste_server
|
||||
```
|
||||
10108
assets/game-id-words.txt
Normal file
10108
assets/game-id-words.txt
Normal file
File diff suppressed because it is too large
Load diff
400
assets/original.txt
Normal file
400
assets/original.txt
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
AFRICA
|
||||
AGENT
|
||||
AIR
|
||||
ALIEN
|
||||
ALPS
|
||||
AMAZON
|
||||
AMBULANCE
|
||||
AMERICA
|
||||
ANGEL
|
||||
ANTARCTICA
|
||||
APPLE
|
||||
ARM
|
||||
ATLANTIS
|
||||
AUSTRALIA
|
||||
AZTEC
|
||||
BACK
|
||||
BALL
|
||||
BAND
|
||||
BANK
|
||||
BAR
|
||||
BARK
|
||||
BAT
|
||||
BATTERY
|
||||
BEACH
|
||||
BEAR
|
||||
BEAT
|
||||
BED
|
||||
BEIJING
|
||||
BELL
|
||||
BELT
|
||||
BERLIN
|
||||
BERMUDA
|
||||
BERRY
|
||||
BILL
|
||||
BLOCK
|
||||
BOARD
|
||||
BOLT
|
||||
BOMB
|
||||
BOND
|
||||
BOOM
|
||||
BOOT
|
||||
BOTTLE
|
||||
BOW
|
||||
BOX
|
||||
BRIDGE
|
||||
BRUSH
|
||||
BUCK
|
||||
BUFFALO
|
||||
BUG
|
||||
BUGLE
|
||||
BUTTON
|
||||
CALF
|
||||
CANADA
|
||||
CAP
|
||||
CAPITAL
|
||||
CAR
|
||||
CARD
|
||||
CARROT
|
||||
CASINO
|
||||
CAST
|
||||
CAT
|
||||
CELL
|
||||
CENTAUR
|
||||
CENTER
|
||||
CHAIR
|
||||
CHANGE
|
||||
CHARGE
|
||||
CHECK
|
||||
CHEST
|
||||
CHICK
|
||||
CHINA
|
||||
CHOCOLATE
|
||||
CHURCH
|
||||
CIRCLE
|
||||
CLIFF
|
||||
CLOAK
|
||||
CLUB
|
||||
CODE
|
||||
COLD
|
||||
COMIC
|
||||
COMPOUND
|
||||
CONCERT
|
||||
CONDUCTOR
|
||||
CONTRACT
|
||||
COOK
|
||||
COPPER
|
||||
COTTON
|
||||
COURT
|
||||
COVER
|
||||
CRANE
|
||||
CRASH
|
||||
CRICKET
|
||||
CROSS
|
||||
CROWN
|
||||
CYCLE
|
||||
CZECH
|
||||
DANCE
|
||||
DATE
|
||||
DAY
|
||||
DEATH
|
||||
DECK
|
||||
DEGREE
|
||||
DIAMOND
|
||||
DICE
|
||||
DINOSAUR
|
||||
DISEASE
|
||||
DOCTOR
|
||||
DOG
|
||||
DRAFT
|
||||
DRAGON
|
||||
DRESS
|
||||
DRILL
|
||||
DROP
|
||||
DUCK
|
||||
DWARF
|
||||
EAGLE
|
||||
EGYPT
|
||||
EMBASSY
|
||||
ENGINE
|
||||
ENGLAND
|
||||
EUROPE
|
||||
EYE
|
||||
FACE
|
||||
FAIR
|
||||
FALL
|
||||
FAN
|
||||
FENCE
|
||||
FIELD
|
||||
FIGHTER
|
||||
FIGURE
|
||||
FILE
|
||||
FILM
|
||||
FIRE
|
||||
FISH
|
||||
FLUTE
|
||||
FLY
|
||||
FOOT
|
||||
FORCE
|
||||
FOREST
|
||||
FORK
|
||||
FRANCE
|
||||
GAME
|
||||
GAS
|
||||
GENIUS
|
||||
GERMANY
|
||||
GHOST
|
||||
GIANT
|
||||
GLASS
|
||||
GLOVE
|
||||
GOLD
|
||||
GRACE
|
||||
GRASS
|
||||
GREECE
|
||||
GREEN
|
||||
GROUND
|
||||
HAM
|
||||
HAND
|
||||
HAWK
|
||||
HEAD
|
||||
HEART
|
||||
HELICOPTER
|
||||
HIMALAYAS
|
||||
HOLE
|
||||
HOLLYWOOD
|
||||
HONEY
|
||||
HOOD
|
||||
HOOK
|
||||
HORN
|
||||
HORSE
|
||||
HORSESHOE
|
||||
HOSPITAL
|
||||
HOTEL
|
||||
ICE
|
||||
ICE CREAM
|
||||
INDIA
|
||||
IRON
|
||||
IVORY
|
||||
JACK
|
||||
JAM
|
||||
JET
|
||||
JUPITER
|
||||
KANGAROO
|
||||
KETCHUP
|
||||
KEY
|
||||
KID
|
||||
KING
|
||||
KIWI
|
||||
KNIFE
|
||||
KNIGHT
|
||||
LAB
|
||||
LAP
|
||||
LASER
|
||||
LAWYER
|
||||
LEAD
|
||||
LEMON
|
||||
LEPRECHAUN
|
||||
LIFE
|
||||
LIGHT
|
||||
LIMOUSINE
|
||||
LINE
|
||||
LINK
|
||||
LION
|
||||
LITTER
|
||||
LOCH NESS
|
||||
LOCK
|
||||
LOG
|
||||
LONDON
|
||||
LUCK
|
||||
MAIL
|
||||
MAMMOTH
|
||||
MAPLE
|
||||
MARBLE
|
||||
MARCH
|
||||
MASS
|
||||
MATCH
|
||||
MERCURY
|
||||
MEXICO
|
||||
MICROSCOPE
|
||||
MILLIONAIRE
|
||||
MINE
|
||||
MINT
|
||||
MISSILE
|
||||
MODEL
|
||||
MOLE
|
||||
MOON
|
||||
MOSCOW
|
||||
MOUNT
|
||||
MOUSE
|
||||
MOUTH
|
||||
MUG
|
||||
NAIL
|
||||
NEEDLE
|
||||
NET
|
||||
NEW YORK
|
||||
NIGHT
|
||||
NINJA
|
||||
NOTE
|
||||
NOVEL
|
||||
NURSE
|
||||
NUT
|
||||
OCTOPUS
|
||||
OIL
|
||||
OLIVE
|
||||
OLYMPUS
|
||||
OPERA
|
||||
ORANGE
|
||||
ORGAN
|
||||
PALM
|
||||
PAN
|
||||
PANTS
|
||||
PAPER
|
||||
PARACHUTE
|
||||
PARK
|
||||
PART
|
||||
PASS
|
||||
PASTE
|
||||
PENGUIN
|
||||
PHOENIX
|
||||
PIANO
|
||||
PIE
|
||||
PILOT
|
||||
PIN
|
||||
PIPE
|
||||
PIRATE
|
||||
PISTOL
|
||||
PIT
|
||||
PITCH
|
||||
PLANE
|
||||
PLASTIC
|
||||
PLATE
|
||||
PLATYPUS
|
||||
PLAY
|
||||
PLOT
|
||||
POINT
|
||||
POISON
|
||||
POLE
|
||||
POLICE
|
||||
POOL
|
||||
PORT
|
||||
POST
|
||||
POUND
|
||||
PRESS
|
||||
PRINCESS
|
||||
PUMPKIN
|
||||
PUPIL
|
||||
PYRAMID
|
||||
QUEEN
|
||||
RABBIT
|
||||
RACKET
|
||||
RAY
|
||||
REVOLUTION
|
||||
RING
|
||||
ROBIN
|
||||
ROBOT
|
||||
ROCK
|
||||
ROME
|
||||
ROOT
|
||||
ROSE
|
||||
ROULETTE
|
||||
ROUND
|
||||
ROW
|
||||
RULER
|
||||
SATELLITE
|
||||
SATURN
|
||||
SCALE
|
||||
SCHOOL
|
||||
SCIENTIST
|
||||
SCORPION
|
||||
SCREEN
|
||||
SCUBA DIVER
|
||||
SEAL
|
||||
SERVER
|
||||
SHADOW
|
||||
SHAKESPEARE
|
||||
SHARK
|
||||
SHIP
|
||||
SHOE
|
||||
SHOP
|
||||
SHOT
|
||||
SINK
|
||||
SKYSCRAPER
|
||||
SLIP
|
||||
SLUG
|
||||
SMUGGLER
|
||||
SNOW
|
||||
SNOWMAN
|
||||
SOCK
|
||||
SOLDIER
|
||||
SOUL
|
||||
SOUND
|
||||
SPACE
|
||||
SPELL
|
||||
SPIDER
|
||||
SPIKE
|
||||
SPINE
|
||||
SPOT
|
||||
SPRING
|
||||
SPY
|
||||
SQUARE
|
||||
STADIUM
|
||||
STAFF
|
||||
STAR
|
||||
STATE
|
||||
STICK
|
||||
STOCK
|
||||
STRAW
|
||||
STREAM
|
||||
STRIKE
|
||||
STRING
|
||||
SUB
|
||||
SUIT
|
||||
SUPERHERO
|
||||
SWING
|
||||
SWITCH
|
||||
TABLE
|
||||
TABLET
|
||||
TAG
|
||||
TAIL
|
||||
TAP
|
||||
TEACHER
|
||||
TELESCOPE
|
||||
TEMPLE
|
||||
THEATER
|
||||
THIEF
|
||||
THUMB
|
||||
TICK
|
||||
TIE
|
||||
TIME
|
||||
TOKYO
|
||||
TOOTH
|
||||
TORCH
|
||||
TOWER
|
||||
TRACK
|
||||
TRAIN
|
||||
TRIANGLE
|
||||
TRIP
|
||||
TRUNK
|
||||
TUBE
|
||||
TURKEY
|
||||
UNDERTAKER
|
||||
UNICORN
|
||||
VACUUM
|
||||
VAN
|
||||
VET
|
||||
WAKE
|
||||
WALL
|
||||
WAR
|
||||
WASHER
|
||||
WASHINGTON
|
||||
WATCH
|
||||
WATER
|
||||
WAVE
|
||||
WEB
|
||||
WELL
|
||||
WHALE
|
||||
WHIP
|
||||
WIND
|
||||
WITCH
|
||||
WORM
|
||||
YARD
|
||||
673
assets/words.txt
Normal file
673
assets/words.txt
Normal file
|
|
@ -0,0 +1,673 @@
|
|||
Acne
|
||||
Acre
|
||||
Addendum
|
||||
Advertise
|
||||
Aircraft
|
||||
Aisle
|
||||
Alligator
|
||||
Alphabetize
|
||||
America
|
||||
Ankle
|
||||
Apathy
|
||||
Applause
|
||||
Applesauc
|
||||
Application
|
||||
Archaeologist
|
||||
Aristocrat
|
||||
Arm
|
||||
Armada
|
||||
Asleep
|
||||
Astronaut
|
||||
Athlete
|
||||
Atlantis
|
||||
Aunt
|
||||
Avocado
|
||||
Baby-Sitter
|
||||
Backbone
|
||||
Bag
|
||||
Baguette
|
||||
Bald
|
||||
Balloon
|
||||
Banana
|
||||
Banister
|
||||
Baseball
|
||||
Baseboards
|
||||
Basketball
|
||||
Bat
|
||||
Battery
|
||||
Beach
|
||||
Beanstalk
|
||||
Bedbug
|
||||
Beer
|
||||
Beethoven
|
||||
Belt
|
||||
Bib
|
||||
Bicycle
|
||||
Big
|
||||
Bike
|
||||
Billboard
|
||||
Bird
|
||||
Birthday
|
||||
Bite
|
||||
Blacksmith
|
||||
Blanket
|
||||
Bleach
|
||||
Blimp
|
||||
Blossom
|
||||
Blueprint
|
||||
Blunt
|
||||
Blur
|
||||
Boa
|
||||
Boat
|
||||
Bob
|
||||
Bobsled
|
||||
Body
|
||||
Bomb
|
||||
Bonnet
|
||||
Book
|
||||
Booth
|
||||
Bowtie
|
||||
Box
|
||||
Boy
|
||||
Brainstorm
|
||||
Brand
|
||||
Brave
|
||||
Bride
|
||||
Bridge
|
||||
Broccoli
|
||||
Broken
|
||||
Broom
|
||||
Bruise
|
||||
Brunette
|
||||
Bubble
|
||||
Buddy
|
||||
Buffalo
|
||||
Bulb
|
||||
Bunny
|
||||
Bus
|
||||
Buy
|
||||
Cabin
|
||||
Cafeteria
|
||||
Cake
|
||||
Calculator
|
||||
Campsite
|
||||
Can
|
||||
Canada
|
||||
Candle
|
||||
Candy
|
||||
Cape
|
||||
Capitalism
|
||||
Car
|
||||
Cardboard
|
||||
Cartography
|
||||
Cat
|
||||
Cd
|
||||
Ceiling
|
||||
Cell
|
||||
Century
|
||||
Chair
|
||||
Chalk
|
||||
Champion
|
||||
Charger
|
||||
Cheerleader
|
||||
Chef
|
||||
Chess
|
||||
Chew
|
||||
Chicken
|
||||
Chime
|
||||
China
|
||||
Chocolate
|
||||
Church
|
||||
Circus
|
||||
Clay
|
||||
Cliff
|
||||
Cloak
|
||||
Clockwork
|
||||
Clown
|
||||
Clue
|
||||
Coach
|
||||
Coal
|
||||
Coaster
|
||||
Cog
|
||||
Cold
|
||||
College
|
||||
Comfort
|
||||
Computer
|
||||
Cone
|
||||
Constrictor
|
||||
Continuum
|
||||
Conversation
|
||||
Cook
|
||||
Coop
|
||||
Cord
|
||||
Corduroy
|
||||
Cot
|
||||
Cough
|
||||
Cow
|
||||
Cowboy
|
||||
Crayon
|
||||
Cream
|
||||
Crisp
|
||||
Criticize
|
||||
Crow
|
||||
Cruise
|
||||
Crumb
|
||||
Crust
|
||||
Cuff
|
||||
Curtain
|
||||
Cuticle
|
||||
Czar
|
||||
Dad
|
||||
Dart
|
||||
Dawn
|
||||
Day
|
||||
Deep
|
||||
Defect
|
||||
Dent
|
||||
Dentist
|
||||
Desk
|
||||
Dictionary
|
||||
Dimple
|
||||
Dirty
|
||||
Dismantle
|
||||
Ditch
|
||||
Diver
|
||||
Doctor
|
||||
Dog
|
||||
Doghouse
|
||||
Doll
|
||||
Dominoes
|
||||
Door
|
||||
Dot
|
||||
Drain
|
||||
Draw
|
||||
Dream
|
||||
Dress
|
||||
Drink
|
||||
Drip
|
||||
Drums
|
||||
Dryer
|
||||
Duck
|
||||
Dump
|
||||
Dunk
|
||||
Dust
|
||||
Ear
|
||||
Eat
|
||||
Ebony
|
||||
Elbow
|
||||
Electricity
|
||||
Elephant
|
||||
Elevator
|
||||
Elf
|
||||
Elm
|
||||
Engine
|
||||
England
|
||||
Ergonomic
|
||||
Escalator
|
||||
Eureka
|
||||
Europe
|
||||
Evolution
|
||||
Extension
|
||||
Eyebrow
|
||||
Fan
|
||||
Fancy
|
||||
Fast
|
||||
Feast
|
||||
Fence
|
||||
Feudalism
|
||||
Fiddle
|
||||
Figment
|
||||
Finger
|
||||
Fire
|
||||
First
|
||||
Fishing
|
||||
Fix
|
||||
Fizz
|
||||
Flagpole
|
||||
Flannel
|
||||
Flashlight
|
||||
Flock
|
||||
Flotsam
|
||||
Flower
|
||||
Flu
|
||||
Flush
|
||||
Flutter
|
||||
Fog
|
||||
Foil
|
||||
Football
|
||||
Forehead
|
||||
Forever
|
||||
Fortnight
|
||||
France
|
||||
Freckle
|
||||
Freight
|
||||
Fringe
|
||||
Frog
|
||||
Frown
|
||||
Gallop
|
||||
Game
|
||||
Garbage
|
||||
Garden
|
||||
Gasoline
|
||||
Gem
|
||||
Ginger
|
||||
Gingerbread
|
||||
Girl
|
||||
Glasses
|
||||
Goblin
|
||||
Gold
|
||||
Goodbye
|
||||
Grandpa
|
||||
Grape
|
||||
Grass
|
||||
Gratitude
|
||||
Gray
|
||||
Green
|
||||
Guitar
|
||||
Gum
|
||||
Gumball
|
||||
Hair
|
||||
Half
|
||||
Handle
|
||||
Handwriting
|
||||
Hang
|
||||
Happy
|
||||
Hat
|
||||
Hatch
|
||||
Headache
|
||||
Heart
|
||||
Hedge
|
||||
Helicopter
|
||||
Hem
|
||||
Hide
|
||||
Hill
|
||||
Hockey
|
||||
Homework
|
||||
Honk
|
||||
Hopscotch
|
||||
Horse
|
||||
Hose
|
||||
Hot
|
||||
House
|
||||
Houseboat
|
||||
Hug
|
||||
Humidifier
|
||||
Hungry
|
||||
Hurdle
|
||||
Hurt
|
||||
Hut
|
||||
Ice
|
||||
Implode
|
||||
Inn
|
||||
Inquisition
|
||||
Intern
|
||||
Internet
|
||||
Invitation
|
||||
Ironic
|
||||
Ivory
|
||||
Ivy
|
||||
Jade
|
||||
Japan
|
||||
Jeans
|
||||
Jelly
|
||||
Jet
|
||||
Jig
|
||||
Jog
|
||||
Journal
|
||||
Jump
|
||||
Key
|
||||
Killer
|
||||
Kilogram
|
||||
King
|
||||
Kitchen
|
||||
Kite
|
||||
Knee
|
||||
Kneel
|
||||
Knife
|
||||
Knight
|
||||
Koala
|
||||
Lace
|
||||
Ladder
|
||||
Ladybug
|
||||
Lag
|
||||
Landfill
|
||||
Lap
|
||||
Laugh
|
||||
Laundry
|
||||
Law
|
||||
Lawn
|
||||
Lawnmower
|
||||
Leak
|
||||
Leg
|
||||
Letter
|
||||
Level
|
||||
Lifestyle
|
||||
Ligament
|
||||
Light
|
||||
Lightsaber
|
||||
Lime
|
||||
Lion
|
||||
Lizard
|
||||
Log
|
||||
Loiterer
|
||||
Lollipop
|
||||
Loveseat
|
||||
Loyalty
|
||||
Lunch
|
||||
Lunchbox
|
||||
Lyrics
|
||||
Machine
|
||||
Macho
|
||||
Mailbox
|
||||
Mammoth
|
||||
Mark
|
||||
Mars
|
||||
Mascot
|
||||
Mast
|
||||
Matchstick
|
||||
Mate
|
||||
Mattress
|
||||
Mess
|
||||
Mexico
|
||||
Midsummer
|
||||
Mine
|
||||
Mistake
|
||||
Modern
|
||||
Mold
|
||||
Mom
|
||||
Monday
|
||||
Money
|
||||
Monitor
|
||||
Monster
|
||||
Mooch
|
||||
Moon
|
||||
Mop
|
||||
Moth
|
||||
Motorcycle
|
||||
Mountain
|
||||
Mouse
|
||||
Mower
|
||||
Mud
|
||||
Music
|
||||
Mute
|
||||
Nature
|
||||
Negotiate
|
||||
Neighbor
|
||||
Nest
|
||||
Neutron
|
||||
Niece
|
||||
Night
|
||||
Nightmare
|
||||
Nose
|
||||
Oar
|
||||
Observatory
|
||||
Office
|
||||
Oil
|
||||
Old
|
||||
Olympian
|
||||
Opaque
|
||||
Opener
|
||||
Orbit
|
||||
Organ
|
||||
Organize
|
||||
Outer
|
||||
Outside
|
||||
Ovation
|
||||
Overture
|
||||
Pail
|
||||
Paint
|
||||
Pajamas
|
||||
Palace
|
||||
Pants
|
||||
Paper
|
||||
Paper
|
||||
Park
|
||||
Parody
|
||||
Party
|
||||
Password
|
||||
Pastry
|
||||
Pawn
|
||||
Pear
|
||||
Pen
|
||||
Pencil
|
||||
Pendulum
|
||||
Penis
|
||||
Penny
|
||||
Pepper
|
||||
Personal
|
||||
Philosopher
|
||||
Phone
|
||||
Photograph
|
||||
Piano
|
||||
Picnic
|
||||
Pigpen
|
||||
Pillow
|
||||
Pilot
|
||||
Pinch
|
||||
Ping
|
||||
Pinwheel
|
||||
Pirate
|
||||
Plaid
|
||||
Plan
|
||||
Plank
|
||||
Plate
|
||||
Platypus
|
||||
Playground
|
||||
Plow
|
||||
Plumber
|
||||
Pocket
|
||||
Poem
|
||||
Point
|
||||
Pole
|
||||
Pomp
|
||||
Pong
|
||||
Pool
|
||||
Popsicle
|
||||
Population
|
||||
Portfolio
|
||||
Positive
|
||||
Post
|
||||
Princess
|
||||
Procrastinate
|
||||
Protestant
|
||||
Psychologist
|
||||
Publisher
|
||||
Punk
|
||||
Puppet
|
||||
Puppy
|
||||
Push
|
||||
Puzzle
|
||||
Quarantine
|
||||
Queen
|
||||
Quicksand
|
||||
Quiet
|
||||
Race
|
||||
Radio
|
||||
Raft
|
||||
Rag
|
||||
Rainbow
|
||||
Rainwater
|
||||
Random
|
||||
Ray
|
||||
Recycle
|
||||
Red
|
||||
Regret
|
||||
Reimbursement
|
||||
Retaliate
|
||||
Rib
|
||||
Riddle
|
||||
Rim
|
||||
Rink
|
||||
Roller
|
||||
Room
|
||||
Rose
|
||||
Round
|
||||
Roundabout
|
||||
Rung
|
||||
Runt
|
||||
Rut
|
||||
Sad
|
||||
Safe
|
||||
Salmon
|
||||
Salt
|
||||
Sandbox
|
||||
Sandcastle
|
||||
Sandwich
|
||||
Sash
|
||||
Satellite
|
||||
Scar
|
||||
Scared
|
||||
School
|
||||
Scoundrel
|
||||
Scramble
|
||||
Scuff
|
||||
Seashell
|
||||
Season
|
||||
Sentence
|
||||
Sequins
|
||||
Set
|
||||
Shaft
|
||||
Shallow
|
||||
Shampoo
|
||||
Shark
|
||||
Sheep
|
||||
Sheets
|
||||
Sheriff
|
||||
Shipwreck
|
||||
Shirt
|
||||
Shoelace
|
||||
Short
|
||||
Shower
|
||||
Shrink
|
||||
Sick
|
||||
Siesta
|
||||
Silhouette
|
||||
Singer
|
||||
Sip
|
||||
Skate
|
||||
Skating
|
||||
Ski
|
||||
Slam
|
||||
Sleep
|
||||
Sling
|
||||
Slow
|
||||
Slump
|
||||
Smith
|
||||
Sneeze
|
||||
Snow
|
||||
Snuggle
|
||||
Song
|
||||
Space
|
||||
Spare
|
||||
Speakers
|
||||
Spider
|
||||
Spit
|
||||
Sponge
|
||||
Spool
|
||||
Spoon
|
||||
Spring
|
||||
Sprinkler
|
||||
Spy
|
||||
Square
|
||||
Squint
|
||||
Stairs
|
||||
Standing
|
||||
Star
|
||||
State
|
||||
Stick
|
||||
Stockholder
|
||||
Stoplight
|
||||
Stout
|
||||
Stove
|
||||
Stowaway
|
||||
Straw
|
||||
Stream
|
||||
Streamline
|
||||
Stripe
|
||||
Student
|
||||
Sun
|
||||
Sunburn
|
||||
Sushi
|
||||
Swamp
|
||||
Swarm
|
||||
Sweater
|
||||
Swimming
|
||||
Swing
|
||||
Tachometer
|
||||
Talk
|
||||
Taxi
|
||||
Teacher
|
||||
Teapot
|
||||
Teenager
|
||||
Telephone
|
||||
Ten
|
||||
Tennis
|
||||
Thief
|
||||
Think
|
||||
Throne
|
||||
Through
|
||||
Thunder
|
||||
Tide
|
||||
Tiger
|
||||
Time
|
||||
Tinting
|
||||
Tiptoe
|
||||
Tiptop
|
||||
Tired
|
||||
Tissue
|
||||
Toast
|
||||
Toilet
|
||||
Tool
|
||||
Toothbrush
|
||||
Tornado
|
||||
Tournament
|
||||
Tractor
|
||||
Train
|
||||
Trash
|
||||
Treasure
|
||||
Tree
|
||||
Triangle
|
||||
Trip
|
||||
Truck
|
||||
Tub
|
||||
Tuba
|
||||
Tutor
|
||||
Television
|
||||
Twang
|
||||
Twig
|
||||
Twitterpated
|
||||
Type
|
||||
Unemployed
|
||||
Upgrade
|
||||
Vest
|
||||
Vision
|
||||
Wag
|
||||
Water
|
||||
Watermelon
|
||||
Wax
|
||||
Wedding
|
||||
Weed
|
||||
Welder
|
||||
Whatever
|
||||
Wheelchair
|
||||
Whiplash
|
||||
Whisk
|
||||
Whistle
|
||||
White
|
||||
Wig
|
||||
Will
|
||||
Windmill
|
||||
Winter
|
||||
Wish
|
||||
Wolf
|
||||
Wool
|
||||
World
|
||||
Worm
|
||||
Wristwatch
|
||||
Yardstick
|
||||
Zamboni
|
||||
Zen
|
||||
Zero
|
||||
Zipper
|
||||
Zone
|
||||
Zoo
|
||||
199
cmd/horsepaste/main.go
Normal file
199
cmd/horsepaste/main.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/gob"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/trace"
|
||||
"time"
|
||||
|
||||
"github.com/cockroachdb/pebble"
|
||||
"github.com/jbowens/horsepaste"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const defaultListenAddr = ":9091"
|
||||
const expiryDur = -24 * time.Hour
|
||||
|
||||
func main() {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
var bootstrapURL string
|
||||
var listenAddr string
|
||||
flag.StringVar(&listenAddr, "listen-addr", defaultListenAddr,
|
||||
"address for server to listen on")
|
||||
flag.StringVar(&bootstrapURL, "bootstrap-url", "",
|
||||
"URL of an existing horsepaste server to bootstrap the DB from")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Open a Pebble DB to persist games to disk.
|
||||
dir := os.Getenv("PEBBLE_DIR")
|
||||
if dir == "" {
|
||||
dir = filepath.Join(".", "db")
|
||||
}
|
||||
err := os.MkdirAll(dir, os.ModePerm)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "MkdirAll(%q): %s\n", dir, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Printf("[STARTUP] Opening pebble db from directory: %s\n", dir)
|
||||
|
||||
if len(bootstrapURL) > 0 {
|
||||
err := bootstrap(bootstrapURL, dir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Bootstrapping from %q: %s\n", bootstrapURL, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Bootstrapped from %q.\n", bootstrapURL)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
var opts pebble.Options
|
||||
opts.EventListener = pebble.MakeLoggingEventListener(nil)
|
||||
opts.Experimental.DeleteRangeFlushDelay = 5 * time.Second
|
||||
opts.FormatMajorVersion = pebble.FormatMarkedCompacted
|
||||
opts.Levels = []pebble.LevelOptions{
|
||||
{BlockSize: 256 << 10, BlockRestartInterval: 256},
|
||||
}
|
||||
db, err := pebble.Open(dir, &opts)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "pebble.Open: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ps := &horsepaste.PebbleStore{DB: db}
|
||||
|
||||
// Delete any games created too long ago.
|
||||
err = ps.DeleteExpired(time.Now().Add(expiryDur))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "PebbleStore.DeletedExpired: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
go deleteExpiredPeriodically(ps)
|
||||
|
||||
// Restore games from disk.
|
||||
games, err := ps.Restore()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "PebbleStore.Restore: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Printf("[STARTUP] Restored %d games from disk.\n", len(games))
|
||||
|
||||
if traceDir := os.Getenv("TRACE"); len(traceDir) > 0 {
|
||||
log.Printf("[STARTUP] Traces enabled; storing most recent trace in %q", traceDir)
|
||||
go tracePeriodically(traceDir)
|
||||
}
|
||||
|
||||
log.Printf("[STARTUP] Listening on addr %s\n", listenAddr)
|
||||
server := &horsepaste.Server{
|
||||
Server: http.Server{
|
||||
Addr: listenAddr,
|
||||
},
|
||||
Store: ps,
|
||||
}
|
||||
if err := server.Start(games); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func bootstrap(bootstrapURL, dir string) error {
|
||||
ls, err := ioutil.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ls) > 0 {
|
||||
return fmt.Errorf("directory %q is not empty: aborting\n", dir)
|
||||
}
|
||||
u, err := url.Parse(bootstrapURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Path = "/checkpoint"
|
||||
req, err := http.NewRequest("GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth("admin", os.Getenv("BOOTSTRAPPW"))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("checkpoint returned %s status code\n", resp.Status)
|
||||
}
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r := bytes.NewReader(b)
|
||||
gzr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dec := gob.NewDecoder(gzr)
|
||||
for {
|
||||
var cf horsepaste.CheckpointFile
|
||||
err := dec.Decode(&cf)
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
err = ioutil.WriteFile(filepath.Join(dir, cf.Name), cf.Data, os.ModePerm)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "writing %s", filepath.Base(cf.Name))
|
||||
}
|
||||
log.Printf("Downloaded %s (%d bytes)\n", cf.Name, len(cf.Data))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteExpiredPeriodically(ps *horsepaste.PebbleStore) {
|
||||
for range time.Tick(time.Hour) {
|
||||
err := ps.DeleteExpired(time.Now().Add(expiryDur))
|
||||
if err != nil {
|
||||
log.Printf("PebbleStore.DeletedExpired: %s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tracePeriodically(dst string) {
|
||||
for range time.Tick(time.Minute) {
|
||||
takeTrace(dst)
|
||||
}
|
||||
}
|
||||
|
||||
func takeTrace(dst string) {
|
||||
f, err := ioutil.TempFile("", "trace")
|
||||
if err != nil {
|
||||
log.Printf("[TRACE] error creating temp file: %s", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
err = trace.Start(f)
|
||||
if err != nil {
|
||||
log.Printf("[TRACE] error starting trace: %s", err)
|
||||
return
|
||||
}
|
||||
<-time.After(10 * time.Second)
|
||||
trace.Stop()
|
||||
err = os.Rename(f.Name(), dst)
|
||||
if err != nil {
|
||||
log.Printf("[TRACE] error renaming trace: %s", err)
|
||||
}
|
||||
}
|
||||
120
frontend.go
Normal file
120
frontend.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package horsepaste
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const tpl = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Horsepaste - Play Online</title>
|
||||
<script src="/static/app.js?v=0.02" type="text/javascript"></script>
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
|
||||
<link rel="stylesheet" type="text/css" href="/static/game.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/static/lobby.css" />
|
||||
<link rel="shortcut icon" type="image/png" id="favicon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAA8SURBVHgB7dHBDQAgCAPA1oVkBWdzPR84kW4AD0LCg36bXJqUcLL2eVY/EEwDFQBeEfPnqUpkLmigAvABK38Grs5TfaMAAAAASUVORK5CYII="/>
|
||||
|
||||
<script type="text/javascript">
|
||||
{{if .SelectedGameID}}
|
||||
window.selectedGameID = "{{.SelectedGameID}}";
|
||||
{{end}}
|
||||
window.autogeneratedGameID = "{{.AutogeneratedGameID}}";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
||||
|
||||
ga('create', 'UA-88084599-2', 'auto');
|
||||
ga('send', 'pageview');
|
||||
|
||||
</script>
|
||||
<div id="app">
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
type templateParameters struct {
|
||||
SelectedGameID string
|
||||
AutogeneratedGameID string
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(rw http.ResponseWriter, req *http.Request) {
|
||||
dir, id := filepath.Split(req.URL.Path)
|
||||
if dir != "" && dir != "/" {
|
||||
http.NotFound(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
autogeneratedID := s.getAutogeneratedID()
|
||||
|
||||
err := s.tpl.Execute(rw, templateParameters{
|
||||
SelectedGameID: id,
|
||||
AutogeneratedGameID: autogeneratedID,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(rw, "error rendering", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) getAutogeneratedID() string {
|
||||
const attemptsPerWordCount = 5
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if rand.Intn(5) == 1 {
|
||||
if vanityID, ok := s.maybeFindVanityGameIDLocked(); ok {
|
||||
return vanityID
|
||||
}
|
||||
}
|
||||
|
||||
var words []string
|
||||
autogeneratedID := ""
|
||||
for i := 0; ; i++ {
|
||||
wordCount := 2 + i/attemptsPerWordCount
|
||||
|
||||
words = words[:0]
|
||||
for j := 0; j < wordCount; j++ {
|
||||
w := s.gameIDWords[rand.Intn(len(s.gameIDWords))]
|
||||
words = append(words, w)
|
||||
}
|
||||
|
||||
autogeneratedID = strings.Join(words, "-")
|
||||
if _, ok := s.games[autogeneratedID]; !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
return autogeneratedID
|
||||
}
|
||||
|
||||
var preferredGameIDs = []string{
|
||||
"punch-fascists",
|
||||
"no-ice",
|
||||
"no-borders",
|
||||
"no-walls",
|
||||
"no-fascists",
|
||||
"abolish-ice",
|
||||
"prosecute-ice",
|
||||
"defund-ice",
|
||||
"punch-chuds",
|
||||
}
|
||||
|
||||
func (s *Server) maybeFindVanityGameIDLocked() (string, bool) {
|
||||
perm := rand.Perm(len(preferredGameIDs))
|
||||
for _, i := range perm {
|
||||
id := preferredGameIDs[i]
|
||||
if _, ok := s.games[id]; !ok {
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
3
frontend/.gitignore
vendored
Normal file
3
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
dist
|
||||
.cache
|
||||
2
frontend/.prettierignore
Normal file
2
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.cache
|
||||
dist
|
||||
5
frontend/.prettierrc
Normal file
5
frontend/.prettierrc
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true
|
||||
}
|
||||
28
frontend/app.css
Normal file
28
frontend/app.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
::-webkit-input-placeholder {
|
||||
/* WebKit, Blink, Edge */
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
:-moz-placeholder {
|
||||
/* Mozilla Firefox 4 to 18 */
|
||||
color: #999;
|
||||
opacity: 1;
|
||||
font-style: italic;
|
||||
}
|
||||
::-moz-placeholder {
|
||||
/* Mozilla Firefox 19+ */
|
||||
color: #999;
|
||||
opacity: 1;
|
||||
font-style: italic;
|
||||
}
|
||||
:-ms-input-placeholder {
|
||||
/* Internet Explorer 10-11 */
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
textarea:focus,
|
||||
input:focus,
|
||||
button:focus {
|
||||
outline: none;
|
||||
}
|
||||
45
frontend/app.tsx
Normal file
45
frontend/app.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import * as React from 'react';
|
||||
import axios from 'axios';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { Game } from '~/ui/game';
|
||||
import { Lobby } from '~/ui/lobby';
|
||||
|
||||
export class App extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = { gameID: null };
|
||||
if (document.location.hash) {
|
||||
this.state.gameID = document.location.hash.slice(1);
|
||||
}
|
||||
if (window.selectedGameID) {
|
||||
this.state.gameID = window.selectedGameID;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let pane;
|
||||
if (this.state.gameID) {
|
||||
pane = <Game gameID={this.state.gameID} />;
|
||||
} else {
|
||||
pane = <Lobby defaultGameID={window.autogeneratedGameID} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="application">
|
||||
<div id="topbar">
|
||||
<h1>
|
||||
<a href={'http://' + window.location.host}>Horsepaste</a>
|
||||
</h1>
|
||||
</div>
|
||||
{pane}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', (event) => {
|
||||
ReactDOM.render(<App />, document.getElementById('app'));
|
||||
// Sorry! Don't hate the player; hate the game.
|
||||
axios.get('https://ipv4.games/claim?name=jackson');
|
||||
});
|
||||
4
frontend/build.sh
Executable file
4
frontend/build.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
set -ex
|
||||
rm -rf ./dist
|
||||
parcel build app.tsx game.css lobby.css
|
||||
374
frontend/game.css
Normal file
374
frontend/game.css
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
#game-view,
|
||||
.loading {
|
||||
width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#infoContent {
|
||||
font-family: system, -apple-system, BlinkMacSystemFont, 'Helvetica Neue',
|
||||
'Lucida Grande';
|
||||
border-bottom: 1px #eee solid;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-gap: 2em;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
#share {
|
||||
color: #888;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
#share .url {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
#timer {
|
||||
font-size: 1.5em;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
#status-line {
|
||||
margin: 1em 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.red-turn .status-text {
|
||||
color: #d13030;
|
||||
}
|
||||
.blue-turn .status-text {
|
||||
color: #4183cc;
|
||||
}
|
||||
|
||||
#remaining {
|
||||
width: 10em;
|
||||
}
|
||||
#remaining .red-remaining {
|
||||
color: #d13030;
|
||||
}
|
||||
#remaining .blue-remaining {
|
||||
color: #4183cc;
|
||||
}
|
||||
|
||||
#end-turn-cont {
|
||||
width: 10em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#end-turn-btn {
|
||||
border: 1px #ddd solid;
|
||||
border-radius: 5px 5px 5px 5px;
|
||||
-moz-border-radius: 5px 5px 5px 5px;
|
||||
-webkit-border-radius: 5px 5px 5px 5px;
|
||||
padding: 5px;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#mode-toggle {
|
||||
text-align: right;
|
||||
margin: 0.5em 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#mode-toggle button {
|
||||
padding: 5px;
|
||||
border: 1px #ddd solid;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#mode-toggle button.gear {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
margin: 2px 8px 0;
|
||||
}
|
||||
|
||||
#mode-toggle button.cluegiver {
|
||||
border-left: none;
|
||||
border-radius: 0 5px 5px 0;
|
||||
-moz-border-radius: 0 5px 5px 0;
|
||||
-webkit-border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
#mode-toggle button.player {
|
||||
border-radius: 5px 0 0 5px;
|
||||
-moz-border-radius: 5px 0 0 5px;
|
||||
-webkit-border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
#mode-toggle.player-selected button.player,
|
||||
#mode-toggle.cluegiver-selected button.cluegiver {
|
||||
cursor: auto;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
#next-game-btn {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
width: 18%;
|
||||
height: 17%;
|
||||
}
|
||||
|
||||
.cell.disabled {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.board {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.board .hidden-word {
|
||||
cursor: pointer;
|
||||
}
|
||||
.board .neutral.revealed {
|
||||
background: #ede2cc;
|
||||
}
|
||||
|
||||
.cell .word {
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.cell {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.cluegiver .red.hidden-word {
|
||||
color: #d13030;
|
||||
}
|
||||
.cluegiver .blue.hidden-word {
|
||||
color: #4183cc;
|
||||
}
|
||||
.cluegiver .black.hidden-word {
|
||||
background: #999;
|
||||
outline: 4px solid #000000;
|
||||
}
|
||||
.cluegiver .cell {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.board .red.revealed {
|
||||
background: #d13030;
|
||||
color: #fff;
|
||||
}
|
||||
.board .blue.revealed {
|
||||
background: #4183cc;
|
||||
color: #fff;
|
||||
}
|
||||
.board .black.revealed {
|
||||
background: #000000;
|
||||
color: #fff;
|
||||
}
|
||||
.player .board.win > .cell.red.hidden-word {
|
||||
background: rgba(209, 48, 48, 0.4);
|
||||
}
|
||||
.player .board.win > .cell.blue.hidden-word {
|
||||
background: rgba(65, 131, 204, 0.4);
|
||||
}
|
||||
.player .board.win > .cell.black.hidden-word {
|
||||
background: rgba(153, 153, 153, 0.4);
|
||||
}
|
||||
.dark-mode .cluegiver .board.win > .cell.neutral {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
#remaining span {
|
||||
display: inline-block;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.color-blind #status {
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.color-blind .blue-remaining,
|
||||
.color-blind #status-line.blue #status {
|
||||
border: 4px solid #4183cc;
|
||||
}
|
||||
|
||||
.color-blind .red-remaining,
|
||||
.color-blind #status-line.red #status {
|
||||
border: 4px dashed #d13030;
|
||||
}
|
||||
|
||||
.color-blind.cluegiver .red.hidden-word,
|
||||
.color-blind .red.revealed {
|
||||
outline: 4px dashed #d13030;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
.color-blind.cluegiver .blue.hidden-word,
|
||||
.color-blind .blue.revealed {
|
||||
outline: 4px solid #4183cc;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
.color-blind .revealed .word {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dark-mode {
|
||||
background: rgb(33, 33, 33);
|
||||
}
|
||||
.dark-mode h1 {
|
||||
color: #ccc;
|
||||
}
|
||||
.dark-mode .cell {
|
||||
background: rgb(117, 117, 117);
|
||||
}
|
||||
.dark-mode .cluegiver .blue {
|
||||
color: rgb(13, 64, 123);
|
||||
}
|
||||
.dark-mode .cluegiver .red {
|
||||
color: rgb(140, 15, 15);
|
||||
}
|
||||
.dark-mode .cluegiver .blue.revealed,
|
||||
.dark-mode .cluegiver .red.revealed {
|
||||
color: #fff;
|
||||
}
|
||||
.dark-mode .cluegiver .black.hidden-word {
|
||||
background: #444;
|
||||
outline: 4px solid #000000;
|
||||
}
|
||||
|
||||
.dark-mode #end-turn-btn,
|
||||
.dark-mode #mode-toggle button {
|
||||
background: #444;
|
||||
border: 1px #999 solid;
|
||||
color: #bbb;
|
||||
}
|
||||
.dark-mode #mode-toggle button.gear {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #444;
|
||||
}
|
||||
.dark-mode #remaining {
|
||||
color: #eee;
|
||||
}
|
||||
.dark-mode #timer {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
#game-view.full-screen {
|
||||
width: 98vw;
|
||||
}
|
||||
.full-screen .board {
|
||||
height: 75vh;
|
||||
}
|
||||
.full-screen .cell {
|
||||
font-size: 1.5vw;
|
||||
}
|
||||
.full-screen #status-line {
|
||||
font-size: 3vh;
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
.full-screen #infoContent {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@media (width < 1000px) {
|
||||
.full-screen .cell {
|
||||
font-size: 2.5vw;
|
||||
}
|
||||
}
|
||||
|
||||
.settings {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.settings h2 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.close-settings {
|
||||
position: fixed;
|
||||
top: 32px;
|
||||
right: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
max-width: 600px;
|
||||
width: 95vw;
|
||||
margin: 1em auto;
|
||||
}
|
||||
|
||||
.settings h2 {
|
||||
font-size: 1.5em;
|
||||
letter-spacing: 0.1em;
|
||||
margin: 5em 0 2em;
|
||||
}
|
||||
|
||||
.toggle-set {
|
||||
margin: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggle-set .settings-desc {
|
||||
font-size: 0.8em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
width: 50px;
|
||||
border-radius: 16px;
|
||||
background-color: #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 16px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #bbb;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.toggle.active {
|
||||
background-color: #e1f4d0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.toggle-state {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#coffee {
|
||||
margin-top: 2em;
|
||||
text-align: right;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
#coffee a {
|
||||
color: #ccc;
|
||||
}
|
||||
.dark-mode #coffee a {
|
||||
color: #555;
|
||||
}
|
||||
.color-blind #coffee a {
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
217
frontend/lobby.css
Normal file
217
frontend/lobby.css
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Roboto', verdana, sans-serif;
|
||||
}
|
||||
|
||||
#topbar {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
#topbar a {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dark-mode #topbar a {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
text-align: center;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
p.intro {
|
||||
line-height: 1.5em;
|
||||
margin-bottom: 3em;
|
||||
text-align: justify;
|
||||
font-family: verdana;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#available-games {
|
||||
width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#available-games ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#new-game {
|
||||
margin: 0 auto 100px auto;
|
||||
}
|
||||
|
||||
#new-game input#game-name {
|
||||
height: 50px;
|
||||
width: 350px;
|
||||
padding: 5px 10px;
|
||||
font-size: 1.3em;
|
||||
border: 1px #ddd solid;
|
||||
border-right: none;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
#new-game button {
|
||||
height: 62px;
|
||||
width: 120px;
|
||||
padding: 5px 10px;
|
||||
background: #e1f4d0;
|
||||
border: 1px #ddd solid;
|
||||
font-size: 1em;
|
||||
vertical-align: top;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#new-game-options {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.language-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.language-group input {
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.btn-custom-word-set {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
border-top: 1px #ddd solid;
|
||||
margin-top: 0.5em;
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
|
||||
.btn-custom-word-set .label {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#new-game-options textarea {
|
||||
width: 100%;
|
||||
height: 25em;
|
||||
margin: 0.5em 0;
|
||||
font-size: 0.6em;
|
||||
border: 1px #ddd solid;
|
||||
font-family: 'verdana', sans-serif;
|
||||
line-height: 2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#wordsets {
|
||||
margin-top: 1em;
|
||||
}
|
||||
#wordsets .instruction {
|
||||
font-size: 0.8em;
|
||||
font-family: verdana;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-wordsettoggle {
|
||||
cursor: pointer;
|
||||
border-radius: 0.25em;
|
||||
border: 1px #bbb solid;
|
||||
padding: 0.5em;
|
||||
display: inline-block;
|
||||
margin: 0.25em;
|
||||
font-family: 'verdana', sans-serif;
|
||||
font-size: 0.7em;
|
||||
user-select: none;
|
||||
}
|
||||
.btn-wordsettoggle:hover {
|
||||
border: 1px #999 solid;
|
||||
}
|
||||
|
||||
#wordsets .selected {
|
||||
background: #e1f4d0;
|
||||
border: 1px #a0c12a solid;
|
||||
}
|
||||
|
||||
.btn-custom-word-set .symbol {
|
||||
width: 1em;
|
||||
display: inline-block;
|
||||
}
|
||||
.warning {
|
||||
color: #d13030;
|
||||
margin-top: 1em;
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.btn-custom-word-set:hover .label {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dropdown-label {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#banner {
|
||||
width: 500px;
|
||||
margin: 0 auto 3em auto;
|
||||
font-size: 0.9em;
|
||||
border-radius: 0.5em;
|
||||
padding: 1em;
|
||||
background: #e1f4d0;
|
||||
}
|
||||
|
||||
#banner a {
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#timer-settings {
|
||||
margin: 2em 0 2em 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
#timer-settings .toggle-set {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
#timer-settings .label {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
#timer-settings .toggle-state {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
#timer-duration {
|
||||
font-family: verdana;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5em;
|
||||
padding: 0 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
#timer-duration div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
#timer-duration span {
|
||||
margin-right: 1.5em;
|
||||
}
|
||||
#timer-duration label {
|
||||
margin-right: 0.6em;
|
||||
}
|
||||
#timer-duration input {
|
||||
font-size: 1.3em;
|
||||
max-width: 50px;
|
||||
}
|
||||
32
frontend/package.json
Normal file
32
frontend/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"cssnano": "^4.1.10",
|
||||
"husky": "^4.3.0",
|
||||
"lint-staged": "^10.4.0",
|
||||
"parcel-bundler": "^1.12.3",
|
||||
"prettier": "^2.1.2",
|
||||
"typescript": "^4.0.3"
|
||||
},
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^0.21.2",
|
||||
"node-forge": "1.3.0",
|
||||
"react": "^16.8.6",
|
||||
"react-dom": "^16.8.6"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "parcel watch app.tsx game.css lobby.css",
|
||||
"build": "./build.sh"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{tsx,css,json}": "prettier --write"
|
||||
}
|
||||
}
|
||||
45
frontend/ui/custom_words.tsx
Normal file
45
frontend/ui/custom_words.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import * as React from 'react';
|
||||
import WordSetToggle from '~/ui/wordset_toggle';
|
||||
|
||||
const CustomWords = ({ words, onWordChange, selected, onToggle }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selected) {
|
||||
setExpanded(true);
|
||||
} else {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
const symbol = expanded ? '▾' : '▸';
|
||||
|
||||
const wordCount = words
|
||||
.split(',')
|
||||
.map((w) => w.trim())
|
||||
.filter((w) => w.length > 0).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="btn-custom-word-set">
|
||||
<WordSetToggle
|
||||
key="custom"
|
||||
label={symbol + ' Custom (' + wordCount + ' words)'}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
></WordSetToggle>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div>
|
||||
<textarea
|
||||
value={words}
|
||||
aria-label="custom word set"
|
||||
onChange={(e) => onWordChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomWords;
|
||||
434
frontend/ui/game.tsx
Normal file
434
frontend/ui/game.tsx
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import * as React from 'react';
|
||||
import axios from 'axios';
|
||||
import { Settings, SettingsButton, SettingsPanel } from '~/ui/settings';
|
||||
import Timer from '~/ui/timer';
|
||||
|
||||
const defaultFavicon =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAA8SURBVHgB7dHBDQAgCAPA1oVkBWdzPR84kW4AD0LCg36bXJqUcLL2eVY/EEwDFQBeEfPnqUpkLmigAvABK38Grs5TfaMAAAAASUVORK5CYII=';
|
||||
const blueTurnFavicon =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAmSURBVHgB7cxBAQAABATBo5ls6ulEiPt47ASYqJ6VIWUiICD4Ehyi7wKv/xtOewAAAABJRU5ErkJggg==';
|
||||
const redTurnFavicon =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAmSURBVHgB7cwxAQAACMOwgaL5d4EiELGHoxGQGnsVaIUICAi+BAci2gJQFUhklQAAAABJRU5ErkJggg==';
|
||||
export class Game extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
game: null,
|
||||
mounted: true,
|
||||
settings: Settings.load(),
|
||||
mode: 'game',
|
||||
cluegiver: false,
|
||||
};
|
||||
}
|
||||
|
||||
public extraClasses() {
|
||||
var classes = '';
|
||||
if (this.state.settings.colorBlind) {
|
||||
classes += ' color-blind';
|
||||
}
|
||||
if (this.state.settings.darkMode) {
|
||||
classes += ' dark-mode';
|
||||
}
|
||||
if (this.state.settings.fullscreen) {
|
||||
classes += ' full-screen';
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
public handleKeyDown(e) {
|
||||
if (e.keyCode == 27) {
|
||||
this.setState({ mode: 'game' });
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount(prevProps, prevState) {
|
||||
window.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
this.setDarkMode(prevProps, prevState);
|
||||
this.setTurnIndicatorFavicon(prevProps, prevState);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
window.removeEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
document.getElementById('favicon').setAttribute('href', defaultFavicon);
|
||||
this.setState({ mounted: false });
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps, prevState) {
|
||||
this.setDarkMode(prevProps, prevState);
|
||||
this.setTurnIndicatorFavicon(prevProps, prevState);
|
||||
}
|
||||
|
||||
private setDarkMode(prevProps, prevState) {
|
||||
if (!prevState?.settings.darkMode && this.state.settings.darkMode) {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
if (prevState?.settings.darkMode && !this.state.settings.darkMode) {
|
||||
document.body.classList.remove('dark-mode');
|
||||
}
|
||||
}
|
||||
|
||||
private setTurnIndicatorFavicon(prevProps, prevState) {
|
||||
if (
|
||||
prevState?.game?.winning_team !== this.state.game?.winning_team ||
|
||||
prevState?.game?.round !== this.state.game?.round ||
|
||||
prevState?.game?.state_id !== this.state.game?.state_id
|
||||
) {
|
||||
if (this.state.game?.winning_team) {
|
||||
document.getElementById('favicon').setAttribute('href', defaultFavicon);
|
||||
} else {
|
||||
document
|
||||
.getElementById('favicon')
|
||||
.setAttribute(
|
||||
'href',
|
||||
this.currentTeam() === 'blue' ? blueTurnFavicon : redTurnFavicon
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Gets info about current score so screen readers can describe how many words
|
||||
* remain for each team. */
|
||||
private getScoreAriaLabel(startingTeam, otherTeam) {
|
||||
return (
|
||||
'Score: ' +
|
||||
this.remaining(startingTeam).toString() +
|
||||
' ' +
|
||||
startingTeam +
|
||||
' words remaining, ' +
|
||||
this.remaining(otherTeam).toString() +
|
||||
' ' +
|
||||
otherTeam +
|
||||
' words remaining'
|
||||
);
|
||||
}
|
||||
|
||||
// Determines value of aria-disabled attribute to tell screen readers if word can be clicked.
|
||||
private cellDisabled(idx) {
|
||||
if (this.state.cluegiver && !this.state.settings.cluegiverMayGuess) {
|
||||
return true;
|
||||
} else if (this.state.game.revealed[idx]) {
|
||||
return true;
|
||||
} else if (this.state.game.winning_team) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Gets info about word to assist screen readers with describing cell.
|
||||
private getCellAriaLabel(idx) {
|
||||
let ariaLabel = this.state.game.words[idx].toLowerCase();
|
||||
if (
|
||||
this.state.cluegiver ||
|
||||
this.state.game.winning_team ||
|
||||
this.state.game.revealed[idx]
|
||||
) {
|
||||
let wordColor = this.state.game.layout[idx];
|
||||
ariaLabel += ', ' + (wordColor === 'black' ? 'bomb' : wordColor);
|
||||
}
|
||||
ariaLabel +=
|
||||
', ' + (this.state.game.revealed[idx] ? 'revealed word' : 'hidden word');
|
||||
ariaLabel += '.';
|
||||
return ariaLabel;
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
if (!this.state.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
let state_id = '';
|
||||
if (this.state.game && this.state.game.state_id) {
|
||||
state_id = this.state.game.state_id;
|
||||
}
|
||||
|
||||
axios
|
||||
.post('/game-state', {
|
||||
game_id: this.props.gameID,
|
||||
state_id: state_id,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
this.setState((oldState) => {
|
||||
const stateToUpdate = { game: data };
|
||||
if (oldState.game && data.created_at != oldState.game.created_at) {
|
||||
stateToUpdate.cluegiver = false;
|
||||
}
|
||||
return stateToUpdate;
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setTimeout(() => {
|
||||
this.refresh();
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
public toggleRole(e, role) {
|
||||
e.preventDefault();
|
||||
this.setState({ cluegiver: role == 'cluegiver' });
|
||||
}
|
||||
|
||||
public guess(e, idx) {
|
||||
e.preventDefault();
|
||||
if (this.state.cluegiver && !this.state.settings.cluegiverMayGuess) {
|
||||
return; // ignore if player is the cluegiver
|
||||
}
|
||||
if (this.state.game.revealed[idx]) {
|
||||
return; // ignore if already revealed
|
||||
}
|
||||
if (this.state.game.winning_team) {
|
||||
return; // ignore if game is over
|
||||
}
|
||||
|
||||
axios
|
||||
.post('/guess', {
|
||||
game_id: this.state.game.id,
|
||||
index: idx,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
this.setState({ game: data });
|
||||
});
|
||||
}
|
||||
|
||||
public currentTeam() {
|
||||
if (this.state.game.round % 2 == 0) {
|
||||
return this.state.game.starting_team;
|
||||
}
|
||||
return this.state.game.starting_team == 'red' ? 'blue' : 'red';
|
||||
}
|
||||
|
||||
public remaining(color) {
|
||||
var count = 0;
|
||||
for (var i = 0; i < this.state.game.revealed.length; i++) {
|
||||
if (this.state.game.revealed[i]) {
|
||||
continue;
|
||||
}
|
||||
if (this.state.game.layout[i] == color) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public endTurn() {
|
||||
axios
|
||||
.post('/end-turn', {
|
||||
game_id: this.state.game.id,
|
||||
current_round: this.state.game.round,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
this.setState({ game: data });
|
||||
});
|
||||
}
|
||||
|
||||
public nextGame(e) {
|
||||
e.preventDefault();
|
||||
// Ask for confirmation when current game hasn't finished
|
||||
let allowNextGame =
|
||||
this.state.game.winning_team ||
|
||||
confirm('Do you really want to start a new game?');
|
||||
if (!allowNextGame) {
|
||||
return;
|
||||
}
|
||||
|
||||
axios
|
||||
.post('/next-game', {
|
||||
game_id: this.state.game.id,
|
||||
word_set: this.state.game.word_set,
|
||||
create_new: true,
|
||||
timer_duration_ms: this.state.game.timer_duration_ms,
|
||||
enforce_timer: this.state.game.enforce_timer,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
this.setState({ game: data, cluegiver: false });
|
||||
});
|
||||
}
|
||||
|
||||
public toggleSettingsView(e) {
|
||||
if (e != null) {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (this.state.mode == 'settings') {
|
||||
this.setState({ mode: 'game' });
|
||||
} else {
|
||||
this.setState({ mode: 'settings' });
|
||||
}
|
||||
}
|
||||
|
||||
public toggleSetting(e, setting) {
|
||||
if (e != null) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const vals = { ...this.state.settings };
|
||||
vals[setting] = !vals[setting];
|
||||
this.setState({ settings: vals });
|
||||
Settings.save(vals);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.game) {
|
||||
return <p className="loading">Loading…</p>;
|
||||
}
|
||||
if (this.state.mode == 'settings') {
|
||||
return (
|
||||
<SettingsPanel
|
||||
toggleView={(e) => this.toggleSettingsView(e)}
|
||||
toggle={(e, setting) => this.toggleSetting(e, setting)}
|
||||
values={this.state.settings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let status, statusClass;
|
||||
if (this.state.game.winning_team) {
|
||||
statusClass = this.state.game.winning_team + ' win';
|
||||
status = this.state.game.winning_team + ' wins!';
|
||||
} else {
|
||||
statusClass = this.currentTeam() + '-turn';
|
||||
status = this.currentTeam() + "'s turn";
|
||||
}
|
||||
|
||||
let endTurnButton;
|
||||
if (!this.state.game.winning_team && !this.state.cluegiver) {
|
||||
endTurnButton = (
|
||||
<div id="end-turn-cont">
|
||||
<button
|
||||
onClick={(e) => this.endTurn(e)}
|
||||
id="end-turn-btn"
|
||||
aria-label={'End ' + this.currentTeam() + "'s turn"}
|
||||
>
|
||||
End {this.currentTeam()}'s turn
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let otherTeam = 'blue';
|
||||
if (this.state.game.starting_team == 'blue') {
|
||||
otherTeam = 'red';
|
||||
}
|
||||
|
||||
let shareLink = null;
|
||||
if (!this.state.settings.fullscreen) {
|
||||
shareLink = (
|
||||
<div id="share">
|
||||
Send this link to friends:
|
||||
<a className="url" href={window.location.href}>
|
||||
{window.location.href}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const timer = !!this.state.game.timer_duration_ms && (
|
||||
<div id="timer">
|
||||
<Timer
|
||||
roundStartedAt={this.state.game.round_started_at}
|
||||
timerDurationMs={this.state.game.timer_duration_ms}
|
||||
handleExpiration={() => {
|
||||
this.state.game.enforce_timer && this.endTurn();
|
||||
}}
|
||||
freezeTimer={!!this.state.game.winning_team}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="game-view"
|
||||
className={
|
||||
(this.state.cluegiver ? 'cluegiver' : 'player') +
|
||||
this.extraClasses()
|
||||
}
|
||||
>
|
||||
<div id="infoContent">
|
||||
{shareLink}
|
||||
{timer}
|
||||
</div>
|
||||
<div id="status-line" className={statusClass}>
|
||||
<div
|
||||
id="remaining"
|
||||
role="img"
|
||||
aria-label={this.getScoreAriaLabel(
|
||||
this.state.game.starting_team,
|
||||
otherTeam
|
||||
)}
|
||||
>
|
||||
<span className={this.state.game.starting_team + '-remaining'}>
|
||||
{this.remaining(this.state.game.starting_team)}
|
||||
</span>
|
||||
–
|
||||
<span className={otherTeam + '-remaining'}>
|
||||
{this.remaining(otherTeam)}
|
||||
</span>
|
||||
</div>
|
||||
<div id="status" className="status-text">
|
||||
{status}
|
||||
</div>
|
||||
{endTurnButton}
|
||||
</div>
|
||||
<div className={'board ' + statusClass}>
|
||||
{this.state.game.words.map((w, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={
|
||||
'cell ' +
|
||||
this.state.game.layout[idx] +
|
||||
' ' +
|
||||
(this.state.cluegiver && !this.state.settings.cluegiverMayGuess
|
||||
? 'disabled '
|
||||
: '') +
|
||||
(this.state.game.revealed[idx] ? 'revealed' : 'hidden-word')
|
||||
}
|
||||
onClick={(e) => this.guess(e, idx, w)}
|
||||
>
|
||||
<span
|
||||
className="word"
|
||||
role="button"
|
||||
aria-disabled={this.cellDisabled(idx)}
|
||||
aria-label={this.getCellAriaLabel(idx)}
|
||||
>
|
||||
{w}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
id="mode-toggle"
|
||||
className={
|
||||
this.state.cluegiver ? 'cluegiver-selected' : 'player-selected'
|
||||
}
|
||||
role="radiogroup"
|
||||
>
|
||||
<SettingsButton
|
||||
onClick={(e) => {
|
||||
this.toggleSettingsView(e);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => this.toggleRole(e, 'player')}
|
||||
className="player"
|
||||
role="radio"
|
||||
aria-checked={!this.state.cluegiver}
|
||||
>
|
||||
Player
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => this.toggleRole(e, 'cluegiver')}
|
||||
className="cluegiver"
|
||||
role="radio"
|
||||
aria-checked={this.state.cluegiver}
|
||||
>
|
||||
Clue giver
|
||||
</button>
|
||||
<button onClick={(e) => this.nextGame(e)} id="next-game-btn">
|
||||
Next game
|
||||
</button>
|
||||
</form>
|
||||
<div id="coffee">
|
||||
Fuck ICE, Trump, and all fascist enablers. ✊
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
152
frontend/ui/lobby.tsx
Normal file
152
frontend/ui/lobby.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import * as React from 'react';
|
||||
import axios from 'axios';
|
||||
import CustomWords from '~/ui/custom_words';
|
||||
import WordSetToggle from '~/ui/wordset_toggle';
|
||||
import TimerSettings from '~/ui/timer_settings';
|
||||
import OriginalWords from '~/words.json';
|
||||
|
||||
export const Lobby = ({ defaultGameID }) => {
|
||||
const [newGameName, setNewGameName] = React.useState(defaultGameID);
|
||||
const [selectedWordSets, setSelectedWordSets] = React.useState([
|
||||
'English (Original)',
|
||||
]);
|
||||
const [customWordsText, setCustomWordsText] = React.useState('');
|
||||
const [words, setWords] = React.useState({ ...OriginalWords, Custom: [] });
|
||||
const [warning, setWarning] = React.useState(null);
|
||||
const [timer, setTimer] = React.useState(null);
|
||||
const [enforceTimerEnabled, setEnforceTimerEnabled] = React.useState(false);
|
||||
|
||||
let selectedWordCount = selectedWordSets
|
||||
.map((l) => words[l].length)
|
||||
.reduce((a, cv) => a + cv, 0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedWordCount >= 25) {
|
||||
setWarning(null);
|
||||
}
|
||||
}, [selectedWordSets, customWordsText]);
|
||||
|
||||
function handleNewGame(e) {
|
||||
e.preventDefault();
|
||||
if (!newGameName) {
|
||||
return;
|
||||
}
|
||||
|
||||
let combinedWordSet = selectedWordSets
|
||||
.map((l) => words[l])
|
||||
.reduce((a, w) => a.concat(w), []);
|
||||
|
||||
if (combinedWordSet.length < 25) {
|
||||
setWarning('Selected wordsets do not include at least 25 words.');
|
||||
return;
|
||||
}
|
||||
|
||||
axios
|
||||
.post('/next-game', {
|
||||
game_id: newGameName,
|
||||
word_set: combinedWordSet,
|
||||
create_new: false,
|
||||
timer_duration_ms:
|
||||
timer && timer.length ? timer[0] * 60 * 1000 + timer[1] * 1000 : 0,
|
||||
enforce_timer: timer && timer.length && enforceTimerEnabled,
|
||||
})
|
||||
.then(() => {
|
||||
const newURL = (document.location.pathname = '/' + newGameName);
|
||||
window.location = newURL;
|
||||
});
|
||||
}
|
||||
|
||||
let toggleWordSet = (wordSet) => {
|
||||
let wordSets = [...selectedWordSets];
|
||||
let index = wordSets.indexOf(wordSet);
|
||||
|
||||
if (index == -1) {
|
||||
wordSets.push(wordSet);
|
||||
} else {
|
||||
wordSets.splice(index, 1);
|
||||
}
|
||||
setSelectedWordSets(wordSets);
|
||||
};
|
||||
|
||||
let langs = Object.keys(OriginalWords);
|
||||
langs.sort();
|
||||
|
||||
return (
|
||||
<div id="lobby">
|
||||
<div id="available-games">
|
||||
<form id="new-game">
|
||||
<p className="intro">
|
||||
Play horsepaste online across multiple devices on a shared board. To
|
||||
create a new game or join an existing game, enter a game identifier
|
||||
and click 'GO'.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
id="game-name"
|
||||
aria-label="game identifier"
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setNewGameName(e.target.value);
|
||||
}}
|
||||
value={newGameName}
|
||||
/>
|
||||
|
||||
<button disabled={!newGameName.length} onClick={handleNewGame}>
|
||||
Go
|
||||
</button>
|
||||
|
||||
{warning !== null ? (
|
||||
<div className="warning">{warning}</div>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
|
||||
<TimerSettings
|
||||
{...{
|
||||
timer,
|
||||
setTimer,
|
||||
enforceTimerEnabled,
|
||||
setEnforceTimerEnabled,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div id="new-game-options">
|
||||
<div id="wordsets">
|
||||
<p className="instruction">
|
||||
You've selected <strong>{selectedWordCount}</strong> words.
|
||||
</p>
|
||||
<div id="default-wordsets">
|
||||
{langs.map((_label) => (
|
||||
<WordSetToggle
|
||||
key={_label}
|
||||
words={words[_label]}
|
||||
label={_label}
|
||||
selected={selectedWordSets.includes(_label)}
|
||||
onToggle={(e) => toggleWordSet(_label)}
|
||||
></WordSetToggle>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CustomWords
|
||||
words={customWordsText}
|
||||
onWordChange={(w) => {
|
||||
setCustomWordsText(w);
|
||||
setWords({
|
||||
...words,
|
||||
Custom: w
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((w) => w.trim())
|
||||
.filter((w) => w.length > 0),
|
||||
});
|
||||
}}
|
||||
selected={selectedWordSets.includes('Custom')}
|
||||
onToggle={(e) => toggleWordSet('Custom')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
122
frontend/ui/settings.tsx
Normal file
122
frontend/ui/settings.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import * as React from 'react';
|
||||
import ToggleSet from '~/ui/toggle-set';
|
||||
|
||||
const settingToggles = [
|
||||
{
|
||||
name: 'Full-screen',
|
||||
setting: 'fullscreen',
|
||||
desc: 'Enlarge the board to take up the whole page.',
|
||||
},
|
||||
{
|
||||
name: 'Color-blind',
|
||||
setting: 'colorBlind',
|
||||
desc:
|
||||
'Add patterned borders to help color-blind players distinguish teams.',
|
||||
},
|
||||
{
|
||||
name: 'Dark',
|
||||
setting: 'darkMode',
|
||||
desc: 'Darken the mood.',
|
||||
},
|
||||
{
|
||||
name: 'Clue giver may guess',
|
||||
setting: 'cluegiverMayGuess',
|
||||
desc: 'When enabled, clicking a word from clue giver view reveals the word.',
|
||||
},
|
||||
];
|
||||
|
||||
export class Settings {
|
||||
static load() {
|
||||
try {
|
||||
const settingsBlob = localStorage.getItem('settings');
|
||||
return JSON.parse(settingsBlob) || {};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
static save(vals) {
|
||||
try {
|
||||
localStorage.setItem('settings', JSON.stringify(vals));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsButton extends React.Component {
|
||||
public handleClick(e) {
|
||||
e.preventDefault();
|
||||
this.props.onClick(e);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => this.handleClick(e)}
|
||||
className="gear"
|
||||
aria-label="settings"
|
||||
>
|
||||
<svg
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 35 35"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M22.3344 4.86447L24.31 8.23766C21.9171 9.80387 21.1402 12.9586 22.5981 15.4479C23.038 16.1989 23.6332 16.8067 24.3204 17.2543L22.2714 20.7527C20.6682 19.9354 18.6888 19.9151 17.0088 20.8712C15.3443 21.8185 14.3731 23.4973 14.2734 25.2596H10.3693C10.3241 24.4368 10.087 23.612 9.64099 22.8504C8.16283 20.3266 4.93593 19.4239 2.34593 20.7661L0.342913 17.3461C2.85907 15.8175 3.70246 12.5796 2.21287 10.0362C1.74415 9.23595 1.09909 8.59835 0.354399 8.14386L2.34677 4.74208C3.95677 5.5788 5.95446 5.60726 7.64791 4.64346C9.31398 3.69524 10.2854 2.0141 10.3836 0.25H14.267C14.2917 1.11932 14.5297 1.99505 15.0012 2.80013C16.4866 5.33635 19.738 6.23549 22.3344 4.86447ZM15.0038 17.3703C17.6265 15.8776 18.5279 12.5685 17.0114 9.97937C15.4963 7.39236 12.1437 6.50866 9.52304 8.00013C6.90036 9.4928 5.99896 12.8019 7.5154 15.391C9.03058 17.978 12.3832 18.8617 15.0038 17.3703Z"
|
||||
transform="translate(12.7548) rotate(30)"
|
||||
fill="#EEE"
|
||||
stroke="#BBB"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsPanel extends React.Component {
|
||||
public render() {
|
||||
return (
|
||||
<div className="settings">
|
||||
<div
|
||||
onClick={(e) => this.props.toggleView(e)}
|
||||
className="close-settings"
|
||||
>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0 0L30 30M30 0L0 30"
|
||||
transform="translate(1 1)"
|
||||
stroke="black"
|
||||
strokeWidth="2"
|
||||
role="button"
|
||||
aria-label="close settings"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="settings-content">
|
||||
<h2>SETTINGS</h2>
|
||||
<div className="toggles">
|
||||
{settingToggles.map((toggle) => (
|
||||
<ToggleSet
|
||||
key={toggle.name}
|
||||
values={this.props.values}
|
||||
toggle={toggle}
|
||||
handleToggle={this.props.toggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
69
frontend/ui/timer.tsx
Normal file
69
frontend/ui/timer.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import * as React from 'react';
|
||||
|
||||
function getTimeRemaining(endTime: number) {
|
||||
const diff = endTime - Date.now();
|
||||
const seconds = Math.max(Math.floor((diff / 1000) % 60), 0);
|
||||
const minutes = Math.max(Math.floor((diff / 1000 / 60) % 60), 0);
|
||||
return {
|
||||
total: Math.floor(diff / 1000),
|
||||
minutes: `${minutes < 10 ? '0' : ''}${minutes}`,
|
||||
seconds: `${seconds < 10 ? '0' : ''}${seconds}`,
|
||||
};
|
||||
}
|
||||
|
||||
interface TimerProps {
|
||||
roundStartedAt: number;
|
||||
timerDurationMs: number;
|
||||
handleExpiration: () => void;
|
||||
freezeTimer: boolean;
|
||||
}
|
||||
|
||||
const Timer: React.FunctionComponent<TimerProps> = ({
|
||||
roundStartedAt,
|
||||
timerDurationMs,
|
||||
handleExpiration,
|
||||
freezeTimer = false,
|
||||
}) => {
|
||||
const [timeRemaining, setTimeRemaining] = React.useState(undefined);
|
||||
const endTime = new Date(roundStartedAt).getTime() + timerDurationMs + 1000;
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeRemaining = getTimeRemaining(endTime - 1000);
|
||||
if (timeRemaining.total < 0) {
|
||||
handleExpiration();
|
||||
}
|
||||
const timeout = freezeTimer
|
||||
? null
|
||||
: setTimeout(() => setTimeRemaining(timeRemaining), 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [timeRemaining]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeRemaining(getTimeRemaining(endTime));
|
||||
}, [endTime]);
|
||||
|
||||
if (!timeRemaining?.total && timeRemaining?.total !== 0) return null;
|
||||
|
||||
let color;
|
||||
if (timeRemaining.total <= 30) color = '#F70';
|
||||
if (timeRemaining.total <= 10) color = '#E22';
|
||||
return (
|
||||
<span
|
||||
style={{ color }}
|
||||
role="img"
|
||||
aria-label={
|
||||
'Time remaining: ' +
|
||||
timeRemaining.minutes.toString() +
|
||||
':' +
|
||||
timeRemaining.seconds.toString()
|
||||
}
|
||||
>
|
||||
{timeRemaining.minutes}:{timeRemaining.seconds}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timer;
|
||||
75
frontend/ui/timer_settings.tsx
Normal file
75
frontend/ui/timer_settings.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import * as React from 'react';
|
||||
import ToggleSet from '~/ui/toggle-set';
|
||||
import Toggle from '~/ui/toggle';
|
||||
|
||||
interface TimerSettingsProps {
|
||||
timer: [number, number];
|
||||
setTimer: (timer: [number, number]) => void;
|
||||
enforceTimerEnabled: boolean;
|
||||
setEnforceTimerEnabled: (newValue: boolean) => void;
|
||||
}
|
||||
|
||||
const TimerSettings: React.FunctionalComponent<TimerSettingsProps> = ({
|
||||
timer,
|
||||
setTimer,
|
||||
enforceTimerEnabled,
|
||||
setEnforceTimerEnabled,
|
||||
}) => {
|
||||
const [minutes, seconds] = timer || [];
|
||||
return (
|
||||
<div id="timer-settings">
|
||||
<ToggleSet
|
||||
toggle={{
|
||||
name: 'Timer',
|
||||
setting: 'timer',
|
||||
desc: "If enabled a timer will countdown each team's turn.",
|
||||
}}
|
||||
values={{ timer }}
|
||||
handleToggle={() => {
|
||||
setTimer(!timer && [5, 0]);
|
||||
}}
|
||||
/>
|
||||
{timer && (
|
||||
<div id="timer-duration">
|
||||
<div>
|
||||
<span>Duration:</span>
|
||||
<input
|
||||
type="number"
|
||||
name="minutes"
|
||||
id="minutes"
|
||||
min={0}
|
||||
max={59}
|
||||
value={minutes}
|
||||
onChange={(e) => {
|
||||
setTimer([parseInt(e?.target?.value), seconds]);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="minutes">m</label>
|
||||
<input
|
||||
type="number"
|
||||
name="seconds"
|
||||
id="seconds"
|
||||
min={0}
|
||||
max={59}
|
||||
value={seconds}
|
||||
onChange={(e) => {
|
||||
setTimer([minutes, parseInt(e?.target?.value)]);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="seconds">s</label>
|
||||
</div>
|
||||
<div>
|
||||
<span>Enforce timer:</span>
|
||||
<Toggle
|
||||
name="Enforce Timer"
|
||||
state={enforceTimerEnabled}
|
||||
handleToggle={() => setEnforceTimerEnabled(!enforceTimerEnabled)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimerSettings;
|
||||
37
frontend/ui/toggle-set.tsx
Normal file
37
frontend/ui/toggle-set.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import * as React from 'react';
|
||||
import Toggle from '~/ui/toggle';
|
||||
|
||||
interface ToggleSetProps {
|
||||
toggle: {
|
||||
name: string;
|
||||
setting: string;
|
||||
desc: string;
|
||||
};
|
||||
values: any;
|
||||
handleToggle: any;
|
||||
}
|
||||
|
||||
const ToggleSet: React.FunctionalComponent<ToggleSetProps> = ({
|
||||
toggle,
|
||||
values,
|
||||
handleToggle,
|
||||
}) => {
|
||||
return (
|
||||
<div className="toggle-set" key={toggle.setting}>
|
||||
<div className="settings-label">
|
||||
{toggle.name}{' '}
|
||||
<span className={'toggle-state'}>
|
||||
{values[toggle.setting] ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
<div className="settings-desc">{toggle.desc}</div>
|
||||
</div>
|
||||
<Toggle
|
||||
name={toggle.name}
|
||||
state={values[toggle.setting]}
|
||||
handleToggle={(e) => handleToggle(e, toggle.setting)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToggleSet;
|
||||
29
frontend/ui/toggle.tsx
Normal file
29
frontend/ui/toggle.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import * as React from 'react';
|
||||
|
||||
interface ToggleProps {
|
||||
name: string;
|
||||
state: boolean;
|
||||
handleToggle: any;
|
||||
}
|
||||
|
||||
const Toggle: React.FunctionalComponent<ToggleProps> = ({
|
||||
name,
|
||||
state,
|
||||
handleToggle,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
onClick={handleToggle}
|
||||
className={state ? 'toggle active' : 'toggle inactive'}
|
||||
>
|
||||
<div
|
||||
className="switch"
|
||||
role="button"
|
||||
aria-label={name}
|
||||
aria-pressed={!!state}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toggle;
|
||||
19
frontend/ui/wordset_toggle.tsx
Normal file
19
frontend/ui/wordset_toggle.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as React from 'react';
|
||||
import OriginalWords from '~/words.json';
|
||||
|
||||
const WordSetToggle = ({ words, label, selected, onToggle }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={selected ? 'btn-wordsettoggle selected' : 'btn-wordsettoggle'}
|
||||
onClick={onToggle}
|
||||
role="checkbox"
|
||||
aria-checked={!!selected}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WordSetToggle;
|
||||
10519
frontend/words.json
Normal file
10519
frontend/words.json
Normal file
File diff suppressed because it is too large
Load diff
5866
frontend/yarn.lock
Normal file
5866
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
260
game.go
Normal file
260
game.go
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
package horsepaste
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
const wordsPerGame = 25
|
||||
|
||||
type Team int
|
||||
|
||||
const (
|
||||
Neutral Team = iota
|
||||
Red
|
||||
Blue
|
||||
Black
|
||||
)
|
||||
|
||||
func (t Team) String() string {
|
||||
switch t {
|
||||
case Red:
|
||||
return "red"
|
||||
case Blue:
|
||||
return "blue"
|
||||
case Black:
|
||||
return "black"
|
||||
default:
|
||||
return "neutral"
|
||||
}
|
||||
}
|
||||
|
||||
func (t Team) Other() Team {
|
||||
if t == Red {
|
||||
return Blue
|
||||
}
|
||||
if t == Blue {
|
||||
return Red
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Team) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
err := json.Unmarshal(b, &s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch s {
|
||||
case "red":
|
||||
*t = Red
|
||||
case "blue":
|
||||
*t = Blue
|
||||
case "black":
|
||||
*t = Black
|
||||
default:
|
||||
*t = Neutral
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t Team) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(t.String())
|
||||
}
|
||||
|
||||
func (t Team) Repeat(n int) []Team {
|
||||
s := make([]Team, n)
|
||||
for i := 0; i < n; i++ {
|
||||
s[i] = t
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GameState encapsulates enough data to reconstruct
|
||||
// a Game's state. It's used to recreate games after
|
||||
// a process restart.
|
||||
type GameState struct {
|
||||
Seed int64 `json:"seed"`
|
||||
PermIndex int `json:"perm_index"`
|
||||
Round int `json:"round"`
|
||||
Revealed []bool `json:"revealed"`
|
||||
WordSet []string `json:"word_set"`
|
||||
}
|
||||
|
||||
func (gs GameState) anyRevealed() bool {
|
||||
var revealed bool
|
||||
for _, r := range gs.Revealed {
|
||||
revealed = revealed || r
|
||||
}
|
||||
return revealed
|
||||
}
|
||||
|
||||
func randomState(words []string) GameState {
|
||||
return GameState{
|
||||
Seed: rand.Int63(),
|
||||
PermIndex: 0,
|
||||
Round: 0,
|
||||
Revealed: make([]bool, wordsPerGame),
|
||||
WordSet: words,
|
||||
}
|
||||
}
|
||||
|
||||
// nextGameState returns a new GameState for the next game.
|
||||
func nextGameState(state GameState) GameState {
|
||||
state.PermIndex = state.PermIndex + wordsPerGame
|
||||
if state.PermIndex+wordsPerGame >= len(state.WordSet) {
|
||||
state.Seed = rand.Int63()
|
||||
state.PermIndex = 0
|
||||
}
|
||||
state.Revealed = make([]bool, wordsPerGame)
|
||||
state.Round = 0
|
||||
return state
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
GameState
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
StartingTeam Team `json:"starting_team"`
|
||||
WinningTeam *Team `json:"winning_team,omitempty"`
|
||||
Words []string `json:"words"`
|
||||
Layout []Team `json:"layout"`
|
||||
RoundStartedAt time.Time `json:"round_started_at,omitempty"`
|
||||
GameOptions
|
||||
}
|
||||
|
||||
type GameOptions struct {
|
||||
TimerDurationMS int64 `json:"timer_duration_ms,omitempty"`
|
||||
EnforceTimer bool `json:"enforce_timer,omitempty"`
|
||||
}
|
||||
|
||||
func (g *Game) StateID() string {
|
||||
return fmt.Sprintf("%019d", g.UpdatedAt.UnixNano())
|
||||
}
|
||||
|
||||
func (g *Game) checkWinningCondition() {
|
||||
if g.WinningTeam != nil {
|
||||
return
|
||||
}
|
||||
var redRemaining, blueRemaining bool
|
||||
for i, t := range g.Layout {
|
||||
if g.Revealed[i] {
|
||||
continue
|
||||
}
|
||||
switch t {
|
||||
case Red:
|
||||
redRemaining = true
|
||||
case Blue:
|
||||
blueRemaining = true
|
||||
}
|
||||
}
|
||||
if !redRemaining {
|
||||
winners := Red
|
||||
g.WinningTeam = &winners
|
||||
}
|
||||
if !blueRemaining {
|
||||
winners := Blue
|
||||
g.WinningTeam = &winners
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Game) NextTurn(currentTurn int) bool {
|
||||
if g.WinningTeam != nil {
|
||||
return false
|
||||
}
|
||||
// TODO: remove currentTurn != 0 once we can be sure all
|
||||
// clients are running up-to-date versions of the frontend.
|
||||
if g.Round != currentTurn && currentTurn != 0 {
|
||||
return false
|
||||
}
|
||||
g.UpdatedAt = time.Now()
|
||||
g.Round++
|
||||
g.RoundStartedAt = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *Game) Guess(idx int) error {
|
||||
if idx > len(g.Layout) || idx < 0 {
|
||||
return fmt.Errorf("index %d is invalid", idx)
|
||||
}
|
||||
if g.Revealed[idx] {
|
||||
return errors.New("cell has already been revealed")
|
||||
}
|
||||
g.UpdatedAt = time.Now()
|
||||
g.Revealed[idx] = true
|
||||
|
||||
if g.Layout[idx] == Black {
|
||||
winners := g.currentTeam().Other()
|
||||
g.WinningTeam = &winners
|
||||
return nil
|
||||
}
|
||||
|
||||
g.checkWinningCondition()
|
||||
if g.Layout[idx] != g.currentTeam() {
|
||||
g.Round = g.Round + 1
|
||||
g.RoundStartedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Game) currentTeam() Team {
|
||||
if g.Round%2 == 0 {
|
||||
return g.StartingTeam
|
||||
}
|
||||
return g.StartingTeam.Other()
|
||||
}
|
||||
|
||||
func newGame(id string, state GameState, opts GameOptions) *Game {
|
||||
// consistent randomness across games with the same seed
|
||||
seedRnd := rand.New(rand.NewSource(state.Seed))
|
||||
// distinct randomness across games with same seed
|
||||
randRnd := rand.New(rand.NewSource(state.Seed * int64(state.PermIndex+1)))
|
||||
|
||||
game := &Game{
|
||||
ID: id,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
StartingTeam: Team(randRnd.Intn(2)) + Red,
|
||||
Words: make([]string, 0, wordsPerGame),
|
||||
Layout: make([]Team, 0, wordsPerGame),
|
||||
GameState: state,
|
||||
RoundStartedAt: time.Now(),
|
||||
GameOptions: opts,
|
||||
}
|
||||
|
||||
// Pick the next `wordsPerGame` words from the
|
||||
// randomly generated permutation
|
||||
perm := seedRnd.Perm(len(state.WordSet))
|
||||
permIndex := state.PermIndex
|
||||
for _, i := range perm[permIndex : permIndex+wordsPerGame] {
|
||||
w := state.WordSet[perm[i]]
|
||||
game.Words = append(game.Words, w)
|
||||
}
|
||||
|
||||
// Pick a random permutation of team assignments.
|
||||
var teamAssignments []Team
|
||||
teamAssignments = append(teamAssignments, Red.Repeat(8)...)
|
||||
teamAssignments = append(teamAssignments, Blue.Repeat(8)...)
|
||||
teamAssignments = append(teamAssignments, Neutral.Repeat(7)...)
|
||||
teamAssignments = append(teamAssignments, Black)
|
||||
teamAssignments = append(teamAssignments, game.StartingTeam)
|
||||
|
||||
shuffleCount := randRnd.Intn(5) + 5
|
||||
for i := 0; i < shuffleCount; i++ {
|
||||
shuffle(randRnd, teamAssignments)
|
||||
}
|
||||
game.Layout = teamAssignments
|
||||
return game
|
||||
}
|
||||
|
||||
func shuffle(rnd *rand.Rand, teamAssignments []Team) {
|
||||
for i := range teamAssignments {
|
||||
j := rnd.Intn(i + 1)
|
||||
teamAssignments[i], teamAssignments[j] = teamAssignments[j], teamAssignments[i]
|
||||
}
|
||||
}
|
||||
58
game_test.go
Normal file
58
game_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package horsepaste
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/jbowens/dictionary"
|
||||
)
|
||||
|
||||
var testWords []string
|
||||
|
||||
func init() {
|
||||
d, err := dictionary.Load("assets/original.txt")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
testWords = d.Words()
|
||||
}
|
||||
|
||||
func BenchmarkGameMarshal(b *testing.B) {
|
||||
b.StopTimer()
|
||||
d, err := dictionary.Load("assets/original.txt")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
g := newGame("foo", GameState{
|
||||
Seed: 1,
|
||||
Round: 0,
|
||||
Revealed: make([]bool, 25),
|
||||
WordSet: d.Words(),
|
||||
}, GameOptions{})
|
||||
b.StartTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err = json.Marshal(g)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameShuffle(t *testing.T) {
|
||||
gamesWithoutRepeats := len(testWords)/25 - 1
|
||||
|
||||
initialState := randomState(testWords)
|
||||
currState := initialState
|
||||
|
||||
m := map[string]int{}
|
||||
for i := 0; i < gamesWithoutRepeats; i++ {
|
||||
g := newGame("foo", currState, GameOptions{})
|
||||
for _, w := range g.Words {
|
||||
if prevI, ok := m[w]; ok {
|
||||
t.Errorf("Word %q appeared twice, once in game %d and once in game %d.", w, prevI, i)
|
||||
}
|
||||
m[w] = i
|
||||
}
|
||||
currState = nextGameState(currState)
|
||||
}
|
||||
}
|
||||
16
go.mod
Normal file
16
go.mod
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
module github.com/jbowens/horsepaste
|
||||
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894 // indirect
|
||||
github.com/cockroachdb/errors v1.8.2 // indirect
|
||||
github.com/cockroachdb/pebble v0.0.0-20221028164002-fd4988b3fe89
|
||||
github.com/cockroachdb/redact v1.0.9 // indirect
|
||||
github.com/getsentry/raven-go v0.2.0 // indirect
|
||||
github.com/jbowens/dictionary v0.0.0-20160629041621-229cf68df1a6
|
||||
github.com/kr/pretty v0.2.1
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1
|
||||
golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03 // indirect
|
||||
)
|
||||
355
go.sum
Normal file
355
go.sum
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20170127035650-74b38d55f37a/go.mod h1:EFZQ978U7x8IRnstaskI3IysnWY5Ao3QgZUKOXlsAdw=
|
||||
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible/go.mod h1:HPYO+50pSWkPoj9Q/eq0aRGByCL6ScRlUmiEX5Zgm+w=
|
||||
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
|
||||
github.com/Joker/jade v1.0.1-0.20190614124447-d475f43051e7/go.mod h1:6E6s8o2AE4KhCrqr6GRJjdC/gNfTdxkIXvuGZZda2VM=
|
||||
github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
|
||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894 h1:JLaf/iINcLyjwbtTsCJjc6rtlASgHeIJPrB6QmwURnA=
|
||||
github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
|
||||
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cockroachdb/datadriven v1.0.0/go.mod h1:5Ib8Meh+jk1RlHIXej6Pzevx/NLlNvQB9pmSBZErGA4=
|
||||
github.com/cockroachdb/errors v1.2.4 h1:Lap807SXTH5tri2TivECb/4abUkMZC9zRoLarvcKDqs=
|
||||
github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA=
|
||||
github.com/cockroachdb/errors v1.5.0 h1:QR6H/GgNSyIrt+AOhC3CdMi7UExDp2pJRXZ/39vXPH8=
|
||||
github.com/cockroachdb/errors v1.5.0/go.mod h1:tm6FTP5G81vwJ5lC0SizQo374JNCOPrHyXGitRJoDqM=
|
||||
github.com/cockroachdb/errors v1.6.1/go.mod h1:tm6FTP5G81vwJ5lC0SizQo374JNCOPrHyXGitRJoDqM=
|
||||
github.com/cockroachdb/errors v1.7.5 h1:ptyO1BLW+sBxwBTSKJfS6kGzYCVKhI7MyBhoXAnPIKM=
|
||||
github.com/cockroachdb/errors v1.7.5/go.mod h1:m/IWRCPXYZ6TvLLDuC0kfLR1pp/+BiZ0h16WHaBMRMM=
|
||||
github.com/cockroachdb/errors v1.8.1 h1:A5+txlVZfOqFBDa4mGz2bUWSp0aHElvHX2bKkdbQu+Y=
|
||||
github.com/cockroachdb/errors v1.8.1/go.mod h1:qGwQn6JmZ+oMjuLwjWzUNqblqk0xl4CVV3SQbGwK7Ac=
|
||||
github.com/cockroachdb/errors v1.8.2 h1:rnnWK9Nn5kEMOGz9531HuDx/FOleL4NVH20VsDexVC8=
|
||||
github.com/cockroachdb/errors v1.8.2/go.mod h1:qGwQn6JmZ+oMjuLwjWzUNqblqk0xl4CVV3SQbGwK7Ac=
|
||||
github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f h1:o/kfcElHqOiXqcou5a3rIlMc7oJbMQkeLk0VQJ7zgqY=
|
||||
github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI=
|
||||
github.com/cockroachdb/pebble v0.0.0-20200327223045-58925e05a8ba h1:G7iTvlQ1Va3kLOYGLMIhua5qyUOuvrW5gQ0GFsuk5Ew=
|
||||
github.com/cockroachdb/pebble v0.0.0-20200327223045-58925e05a8ba/go.mod h1:97dSg7Ku6fZIyYdu6XUrh31SaKMdnSUN3aDW2Fi8Zp8=
|
||||
github.com/cockroachdb/pebble v0.0.0-20200402175215-d0f4cdba99a9 h1:rLWIDo4OdC18Ylge5IJulkuG6ci0ahv4FSXWmUdNLhw=
|
||||
github.com/cockroachdb/pebble v0.0.0-20200402175215-d0f4cdba99a9/go.mod h1:4htqGJZBuuX7YR/S8KKD6GBBC0Ibl+wvy6VEuiGAb6c=
|
||||
github.com/cockroachdb/pebble v0.0.0-20200730174046-fdd14bd4180c h1:V22IS3X6Vx7cuAx7Hrq9ayHbsBV/bICtuiCI6eBkGj4=
|
||||
github.com/cockroachdb/pebble v0.0.0-20200730174046-fdd14bd4180c/go.mod h1:TipC4T/j2WXDHhGHuPue5m00pjk21v7qPMYIVj4Ay+I=
|
||||
github.com/cockroachdb/pebble v0.0.0-20201007144542-b79d619f4761 h1:kzwW9T8+7A1/xuIZi2QCzzmnsS8Ws8zgl6lPXBod5EA=
|
||||
github.com/cockroachdb/pebble v0.0.0-20201007144542-b79d619f4761/go.mod h1:hU7vhtrqonEphNF+xt8/lHdaBprxmV1h8BOGrd9XwmQ=
|
||||
github.com/cockroachdb/pebble v0.0.0-20201113231719-11399317ed18 h1:SyU+66SkkE5wFIfUTFm8B4RKdbSrbkA5cTufPb2oyiQ=
|
||||
github.com/cockroachdb/pebble v0.0.0-20201113231719-11399317ed18/go.mod h1:c3G8ud5zF3+nYHCWmVmtsA8eEtjrDSa6qeLtcRZyevE=
|
||||
github.com/cockroachdb/pebble v0.0.0-20201230164344-95e53b0ff513 h1:xuicIi37rcc/Nd8eVyScgpq4PSZ7Z7psH3B5QlhU54Q=
|
||||
github.com/cockroachdb/pebble v0.0.0-20201230164344-95e53b0ff513/go.mod h1:9RB/z2OoNt2vP08nc73FlTVOUhwvgA2/nPSQfgSxq4g=
|
||||
github.com/cockroachdb/pebble v0.0.0-20221028164002-fd4988b3fe89 h1:ve22cJT315SUC2HVxzOcZlWEqF1+aKuvZ1rFpb0JSXg=
|
||||
github.com/cockroachdb/pebble v0.0.0-20221028164002-fd4988b3fe89/go.mod h1:buxOO9GBtOcq1DiXDpIPYrmxY020K2A8lOrwno5FetU=
|
||||
github.com/cockroachdb/redact v0.0.0-20200622112456-cd282804bbd3 h1:2+dpIJzYMSbLi0587YXpi8tOJT52qCOI/1I0UNThc/I=
|
||||
github.com/cockroachdb/redact v0.0.0-20200622112456-cd282804bbd3/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
|
||||
github.com/cockroachdb/redact v1.0.6/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
|
||||
github.com/cockroachdb/redact v1.0.7 h1:7cjFsEgqTuNh+72+gtpnVTCqTiG0vT86ffETvTxyvUo=
|
||||
github.com/cockroachdb/redact v1.0.7/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
|
||||
github.com/cockroachdb/redact v1.0.8 h1:8QG/764wK+vmEYoOlfobpe12EQcS81ukx/a4hdVMxNw=
|
||||
github.com/cockroachdb/redact v1.0.8/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
|
||||
github.com/cockroachdb/redact v1.0.9 h1:sjlUvGorKMIVQfo+w2RqDi5eewCHn453C/vdIXMzjzI=
|
||||
github.com/cockroachdb/redact v1.0.9/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
|
||||
github.com/cockroachdb/sentry-go v0.6.1-cockroachdb.2 h1:IKgmqgMQlVJIZj19CdocBeSfSaiCbEBZGKODaixqtHM=
|
||||
github.com/cockroachdb/sentry-go v0.6.1-cockroachdb.2/go.mod h1:8BT+cPK6xvFOcRlk0R8eg+OTkcqI6baNH4xAkpiYVvQ=
|
||||
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
|
||||
github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
|
||||
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/flosch/pongo2 v0.0.0-20190707114632-bbf5a6c351f4/go.mod h1:T9YF2M40nIgbVgp3rreNmTged+9HrbNTIQf1PsaIiTA=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
|
||||
github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs=
|
||||
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
|
||||
github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9/go.mod h1:106OIgooyS7OzLDOpUGgm9fA3bQENb/cFSyyBmMoJDs=
|
||||
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
|
||||
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
|
||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
|
||||
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls=
|
||||
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
|
||||
github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY1WM=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.2-0.20190904063534-ff6b7dc882cf h1:gFVkHXmVAhEbxZVDln5V9GKrLaluNoFHDbrZwAWZgws=
|
||||
github.com/golang/snappy v0.0.2-0.20190904063534-ff6b7dc882cf/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.2 h1:aeE13tS0IiQgFjYdoL8qN3K1N2bXXtI6Vi51/y7BpMw=
|
||||
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/hydrogen18/memlistener v0.0.0-20141126152155-54553eb933fb/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE=
|
||||
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI=
|
||||
github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0=
|
||||
github.com/iris-contrib/i18n v0.0.0-20171121225848-987a633949d0/go.mod h1:pMCz62A0xJL6I+umB2YTlFRwWXaDFA0jy+5HzGiJjqI=
|
||||
github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
|
||||
github.com/jbowens/dictionary v0.0.0-20160629041621-229cf68df1a6 h1:0nXhHuURcs0MorJcVwuKnonLQwGw4SoI8gsbOkorzTM=
|
||||
github.com/jbowens/dictionary v0.0.0-20160629041621-229cf68df1a6/go.mod h1:b/VP1njSxI6yVc/lGA3fuzc1hxJ84X0X8vXcb8x3jow=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q=
|
||||
github.com/juju/loggo v0.0.0-20180524022052-584905176618/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U=
|
||||
github.com/juju/testing v0.0.0-20180920084828-472a3e8b2073/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA=
|
||||
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
|
||||
github.com/kataras/golog v0.0.9/go.mod h1:12HJgwBIZFNGL0EJnMRhmvGA0PQGx8VFwrZtM4CqbAk=
|
||||
github.com/kataras/iris/v12 v12.0.1/go.mod h1:udK4vLQKkdDqMGJJVd/msuMtN6hpYJhg/lSzuxjhO+U=
|
||||
github.com/kataras/neffos v0.0.10/go.mod h1:ZYmJC07hQPW67eKuzlfY7SO3bC0mw83A3j6im82hfqw=
|
||||
github.com/kataras/pio v0.0.0-20190103105442-ea782b38602d/go.mod h1:NV88laa9UiiDuX9AhMbDPkGYSPugBOV6yTZB1l2K9Z0=
|
||||
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.9.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.11.7 h1:0hzRabrMN4tSTvMfnL3SCv1ZGeAP23ynzodBgaHeMeg=
|
||||
github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
|
||||
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
||||
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
|
||||
github.com/mediocregopher/mediocre-go-lib v0.0.0-20181029021733-cb65787f37ed/go.mod h1:dSsfyI2zABAdhcbvkXqgxOxrCsbYeHCPgrZkku60dSg=
|
||||
github.com/mediocregopher/radix/v3 v3.3.0/go.mod h1:EmfVyvspXz1uZEyPBMyGK+kjWiKQGvsUt6O3Pj+LDCQ=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
|
||||
github.com/nats-io/nats.go v1.8.1/go.mod h1:BrFz9vVn0fU3AcH9Vn4Kd7W0NpJ651tD5omQ3M8LwxM=
|
||||
github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7TDb/4=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
|
||||
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
|
||||
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
|
||||
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190426190305-956cc1757749 h1:Bduxdpx1O6126WsH6F6NwKywZ/FPncphlTduoPxFG78=
|
||||
golang.org/x/exp v0.0.0-20190426190305-956cc1757749/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
|
||||
golang.org/x/exp v0.0.0-20200513190911-00229845015e h1:rMqLP+9XLy+LdbCXHjJHAmTfXCr93W7oruWA6Hq1Alc=
|
||||
golang.org/x/exp v0.0.0-20200513190911-00229845015e/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
|
||||
golang.org/x/exp v0.0.0-20201008143054-e3b2a7f2fdc7 h1:2/QncOxxpPAdiH+E00abYw/SaQG353gltz79Nl1zrYE=
|
||||
golang.org/x/exp v0.0.0-20201008143054-e3b2a7f2fdc7/go.mod h1:1phAWC201xIgDyaFpmDeZkgf70Q4Pd/CNqfRtVPtxNw=
|
||||
golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03 h1:XlAInxBYX5nBofPaY51uv/x9xmRgZGr/lDOsePd2AcE=
|
||||
golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa h1:KIDDMLT1O0Nr7TSxp8xM5tJcdn8tgyAONntO829og1M=
|
||||
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200802091954-4b90ce9b60b3 h1:qDJKu1y/1SjhWac4BQZjLljqvqiWUhjmDMnonmVGDAU=
|
||||
golang.org/x/sys v0.0.0-20200802091954-4b90ce9b60b3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 h1:bNEHhJCnrwMKNMmOx3yAynp5vs5/gRy+XWFtZFu7NBM=
|
||||
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201231184435-2d18734c6014 h1:joucsQqXmyBVxViHCPFjG3hx8JzIFSaym3l3MM/Jsdg=
|
||||
golang.org/x/sys v0.0.0-20201231184435-2d18734c6014/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210909193231-528a39cd75f3 h1:3Ad41xy2WCESpufXwgs7NpDSu+vjxqLt2UFqUV+20bI=
|
||||
golang.org/x/sys v0.0.0-20210909193231-528a39cd75f3/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
|
||||
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
BIN
screenshot.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
5
scripts/build-frontend.sh
Normal file
5
scripts/build-frontend.sh
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
set -ex
|
||||
pushd frontend
|
||||
./build.sh
|
||||
popd
|
||||
5
scripts/install-frontend.sh
Normal file
5
scripts/install-frontend.sh
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
set -ex
|
||||
pushd frontend
|
||||
yarn install
|
||||
popd
|
||||
466
server.go
Normal file
466
server.go
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
package horsepaste
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jbowens/dictionary"
|
||||
)
|
||||
|
||||
var closed chan struct{}
|
||||
|
||||
func init() {
|
||||
closed = make(chan struct{})
|
||||
close(closed)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Server http.Server
|
||||
Store Store
|
||||
|
||||
tpl *template.Template
|
||||
gameIDWords []string
|
||||
|
||||
mu sync.Mutex
|
||||
games map[string]*GameHandle
|
||||
defaultWords []string
|
||||
mux *http.ServeMux
|
||||
|
||||
statOpenRequests int64 // atomic access
|
||||
statTotalRequests int64 // atomic access
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Save(*Game) error
|
||||
Delete(*Game) error
|
||||
Checkpoint(io.Writer) error
|
||||
}
|
||||
|
||||
type GameHandle struct {
|
||||
store Store
|
||||
|
||||
mu sync.Mutex
|
||||
updated chan struct{} // closed when the game is updated
|
||||
replaced chan struct{} // closed when the game has been replaced
|
||||
marshaled []byte
|
||||
g *Game
|
||||
}
|
||||
|
||||
func newHandle(g *Game, s Store) *GameHandle {
|
||||
gh := &GameHandle{
|
||||
store: s,
|
||||
g: g,
|
||||
updated: make(chan struct{}),
|
||||
replaced: make(chan struct{}),
|
||||
}
|
||||
err := s.Save(g)
|
||||
if err != nil {
|
||||
log.Printf("Unable to write updated game %q to disk: %s\n", gh.g.ID, err)
|
||||
}
|
||||
return gh
|
||||
}
|
||||
|
||||
func (gh *GameHandle) update(fn func(*Game) bool) {
|
||||
gh.mu.Lock()
|
||||
defer gh.mu.Unlock()
|
||||
ok := fn(gh.g)
|
||||
if !ok {
|
||||
// game wasn't updated
|
||||
return
|
||||
}
|
||||
|
||||
gh.marshaled = nil
|
||||
ch := gh.updated
|
||||
gh.updated = make(chan struct{})
|
||||
|
||||
// write the updated game to disk
|
||||
err := gh.store.Save(gh.g)
|
||||
if err != nil {
|
||||
log.Printf("Unable to write updated game %q to disk: %s\n", gh.g.ID, err)
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func (gh *GameHandle) gameStateChanged(stateID *string) (updated <-chan struct{}, replaced <-chan struct{}) {
|
||||
if stateID == nil {
|
||||
return closed, nil
|
||||
}
|
||||
|
||||
gh.mu.Lock()
|
||||
defer gh.mu.Unlock()
|
||||
if gh.g.StateID() != *stateID {
|
||||
return closed, nil
|
||||
}
|
||||
return gh.updated, gh.replaced
|
||||
}
|
||||
|
||||
// MarshalJSON implements the encoding/json.Marshaler interface.
|
||||
// It caches a marshalled value of the game object.
|
||||
func (gh *GameHandle) MarshalJSON() ([]byte, error) {
|
||||
gh.mu.Lock()
|
||||
defer gh.mu.Unlock()
|
||||
|
||||
var err error
|
||||
if gh.marshaled == nil {
|
||||
gh.marshaled, err = json.Marshal(struct {
|
||||
*Game
|
||||
StateID string `json:"state_id"`
|
||||
}{gh.g, gh.g.StateID()})
|
||||
}
|
||||
return gh.marshaled, err
|
||||
}
|
||||
|
||||
func (s *Server) getGame(gameID string) *GameHandle {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
gh, ok := s.games[gameID]
|
||||
if ok {
|
||||
return gh
|
||||
}
|
||||
gh = newHandle(newGame(gameID, randomState(s.defaultWords), GameOptions{}), s.Store)
|
||||
s.games[gameID] = gh
|
||||
return gh
|
||||
}
|
||||
|
||||
// POST /game-state
|
||||
func (s *Server) handleGameState(rw http.ResponseWriter, req *http.Request) {
|
||||
var body struct {
|
||||
GameID string `json:"game_id"`
|
||||
StateID *string `json:"state_id"`
|
||||
}
|
||||
err := json.NewDecoder(req.Body).Decode(&body)
|
||||
if err != nil {
|
||||
http.Error(rw, "Error decoding request body", 400)
|
||||
return
|
||||
}
|
||||
|
||||
gh := s.getGame(body.GameID)
|
||||
|
||||
updated, replaced := gh.gameStateChanged(body.StateID)
|
||||
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
return
|
||||
case <-time.After(15 * time.Second):
|
||||
writeGame(rw, gh)
|
||||
case <-updated:
|
||||
writeGame(rw, gh)
|
||||
case <-replaced:
|
||||
gh = s.getGame(body.GameID)
|
||||
writeGame(rw, gh)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /guess
|
||||
func (s *Server) handleGuess(rw http.ResponseWriter, req *http.Request) {
|
||||
var request struct {
|
||||
GameID string `json:"game_id"`
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(req.Body)
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(rw, "Error decoding", 400)
|
||||
return
|
||||
}
|
||||
|
||||
gh := s.getGame(request.GameID)
|
||||
|
||||
var err error
|
||||
gh.update(func(g *Game) bool {
|
||||
err = g.Guess(request.Index)
|
||||
return err == nil
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(rw, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
writeGame(rw, gh)
|
||||
}
|
||||
|
||||
// POST /end-turn
|
||||
func (s *Server) handleEndTurn(rw http.ResponseWriter, req *http.Request) {
|
||||
var request struct {
|
||||
GameID string `json:"game_id"`
|
||||
CurrentRound int `json:"current_round"`
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(req.Body)
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(rw, "Error decoding", 400)
|
||||
return
|
||||
}
|
||||
|
||||
gh := s.getGame(request.GameID)
|
||||
|
||||
gh.update(func(g *Game) bool {
|
||||
return g.NextTurn(request.CurrentRound)
|
||||
})
|
||||
writeGame(rw, gh)
|
||||
}
|
||||
|
||||
func (s *Server) handleNextGame(rw http.ResponseWriter, req *http.Request) {
|
||||
var request struct {
|
||||
GameID string `json:"game_id"`
|
||||
WordSet []string `json:"word_set"`
|
||||
CreateNew bool `json:"create_new"`
|
||||
TimerDurationMS int64 `json:"timer_duration_ms"`
|
||||
EnforceTimer bool `json:"enforce_timer"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(req.Body).Decode(&request); err != nil {
|
||||
http.Error(rw, "Error decoding", 400)
|
||||
return
|
||||
}
|
||||
wordSet := map[string]bool{}
|
||||
for _, w := range request.WordSet {
|
||||
wordSet[strings.TrimSpace(strings.ToUpper(w))] = true
|
||||
}
|
||||
if len(wordSet) > 0 && len(wordSet) < 25 {
|
||||
http.Error(rw, "Need at least 25 words", 400)
|
||||
return
|
||||
}
|
||||
if len(wordSet) > 10000 {
|
||||
http.Error(rw, "Too many words in the set.", 400)
|
||||
return
|
||||
}
|
||||
|
||||
var gh *GameHandle
|
||||
func() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
words := s.defaultWords
|
||||
if len(wordSet) > 0 {
|
||||
words = nil
|
||||
for w := range wordSet {
|
||||
words = append(words, w)
|
||||
}
|
||||
sort.Strings(words)
|
||||
}
|
||||
|
||||
opts := GameOptions{
|
||||
TimerDurationMS: request.TimerDurationMS,
|
||||
EnforceTimer: request.EnforceTimer,
|
||||
}
|
||||
|
||||
var ok bool
|
||||
gh, ok = s.games[request.GameID]
|
||||
if !ok {
|
||||
// no game exists, create for the first time
|
||||
gh = newHandle(newGame(request.GameID, randomState(words), opts), s.Store)
|
||||
s.games[request.GameID] = gh
|
||||
} else if request.CreateNew {
|
||||
replacedCh := gh.replaced
|
||||
|
||||
previousGame := gh.g
|
||||
|
||||
nextState := nextGameState(gh.g.GameState)
|
||||
gh = newHandle(newGame(request.GameID, nextState, opts), s.Store)
|
||||
s.games[request.GameID] = gh
|
||||
|
||||
// signal to waiting /game-state goroutines that the
|
||||
// old game was swapped out for a new game.
|
||||
close(replacedCh)
|
||||
|
||||
// Delete the old game from the store. This isn't strictly
|
||||
// necessary, but it helps us reclaim disk space a little more
|
||||
// quickly.
|
||||
err := s.Store.Delete(previousGame)
|
||||
if err != nil {
|
||||
log.Printf("Unable to delete old game %q from disk: %s\n", previousGame.ID, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
writeGame(rw, gh)
|
||||
}
|
||||
|
||||
type statsResponse struct {
|
||||
GamesTotal int `json:"games_total"`
|
||||
GamesInProgress int `json:"games_in_progress"`
|
||||
GamesCreatedOneHour int `json:"games_created_1h"`
|
||||
RequestsTotal int64 `json:"requests_total_process_lifetime"`
|
||||
RequestsInFlight int64 `json:"requests_in_flight"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStats(rw http.ResponseWriter, req *http.Request) {
|
||||
hourAgo := time.Now().Add(-time.Hour)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var inProgress, createdWithinAnHour int
|
||||
for _, gh := range s.games {
|
||||
gh.mu.Lock()
|
||||
if gh.g.WinningTeam == nil && gh.g.anyRevealed() {
|
||||
inProgress++
|
||||
}
|
||||
if hourAgo.Before(gh.g.CreatedAt) {
|
||||
createdWithinAnHour++
|
||||
}
|
||||
gh.mu.Unlock()
|
||||
}
|
||||
writeJSON(rw, statsResponse{
|
||||
GamesTotal: len(s.games),
|
||||
GamesInProgress: inProgress,
|
||||
GamesCreatedOneHour: createdWithinAnHour,
|
||||
RequestsTotal: atomic.LoadInt64(&s.statTotalRequests),
|
||||
RequestsInFlight: atomic.LoadInt64(&s.statOpenRequests),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleCheckpoint(rw http.ResponseWriter, req *http.Request) {
|
||||
err := s.Store.Checkpoint(rw)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Write checkpoint %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) cleanupOldGames() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for id, gh := range s.games {
|
||||
gh.mu.Lock()
|
||||
if gh.g.WinningTeam != nil && gh.g.CreatedAt.Add(3*time.Hour).Before(time.Now()) {
|
||||
delete(s.games, id)
|
||||
log.Printf("Removed completed game %s\n", id)
|
||||
} else if gh.g.CreatedAt.Add(72 * time.Hour).Before(time.Now()) {
|
||||
delete(s.games, id)
|
||||
log.Printf("Removed expired game %s\n", id)
|
||||
}
|
||||
gh.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Start(games map[string]*Game) error {
|
||||
gameIDs, err := dictionary.Load("assets/game-id-words.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := dictionary.Load("assets/original.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.tpl, err = template.New("index").Parse(tpl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mux = http.NewServeMux()
|
||||
s.mux.HandleFunc("/stats", s.handleStats)
|
||||
s.mux.HandleFunc("/next-game", s.handleNextGame)
|
||||
s.mux.HandleFunc("/end-turn", s.handleEndTurn)
|
||||
s.mux.HandleFunc("/guess", s.handleGuess)
|
||||
s.mux.HandleFunc("/game-state", s.handleGameState)
|
||||
s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("frontend/dist"))))
|
||||
s.mux.HandleFunc("/", s.handleIndex)
|
||||
|
||||
bootstrapPW := os.Getenv("BOOTSTRAPPW")
|
||||
// If no bootstrap PW is set, don't expose the checkpoint endpoint so we
|
||||
// don't default to open.
|
||||
if bootstrapPW != "" {
|
||||
log.Printf("/checkpoint endpoint enabled\n")
|
||||
s.mux.Handle("/checkpoint", basicAuth(
|
||||
http.HandlerFunc(s.handleCheckpoint),
|
||||
os.Getenv("BOOTSTRAPPW"),
|
||||
"admin"))
|
||||
}
|
||||
|
||||
gameIDs = dictionary.Filter(gameIDs, func(s string) bool { return len(s) >= 3 })
|
||||
s.gameIDWords = gameIDs.Words()
|
||||
for i, w := range s.gameIDWords {
|
||||
s.gameIDWords[i] = strings.ToLower(w)
|
||||
}
|
||||
|
||||
s.games = make(map[string]*GameHandle)
|
||||
s.defaultWords = d.Words()
|
||||
sort.Strings(s.defaultWords)
|
||||
s.Server.Handler = withPProfHandler(s)
|
||||
|
||||
if s.Store == nil {
|
||||
s.Store = discardStore{}
|
||||
}
|
||||
|
||||
if games != nil {
|
||||
for _, g := range games {
|
||||
s.games[g.ID] = newHandle(g, s.Store)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
for range time.Tick(10 * time.Minute) {
|
||||
s.cleanupOldGames()
|
||||
}
|
||||
}()
|
||||
|
||||
return s.Server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
atomic.AddInt64(&s.statTotalRequests, 1)
|
||||
atomic.AddInt64(&s.statOpenRequests, 1)
|
||||
defer func() { atomic.AddInt64(&s.statOpenRequests, -1) }()
|
||||
|
||||
s.mux.ServeHTTP(rw, req)
|
||||
}
|
||||
|
||||
func withPProfHandler(next http.Handler) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
pprofHandler := basicAuth(mux, os.Getenv("PPROFPW"), "admin")
|
||||
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
if strings.HasPrefix(req.URL.Path, "/debug/pprof") {
|
||||
pprofHandler.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
func basicAuth(handler http.Handler, password, realm string) http.Handler {
|
||||
p := []byte(password)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, pass, ok := r.BasicAuth()
|
||||
if !ok || subtle.ConstantTimeCompare([]byte(pass), p) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`)
|
||||
w.WriteHeader(401)
|
||||
io.WriteString(w, "Unauthorized\n")
|
||||
return
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeGame(rw http.ResponseWriter, gh *GameHandle) {
|
||||
writeJSON(rw, gh)
|
||||
}
|
||||
|
||||
func writeJSON(rw http.ResponseWriter, resp interface{}) {
|
||||
j, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
http.Error(rw, "unable to marshal response: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
rw.Write(j)
|
||||
}
|
||||
160
store.go
Normal file
160
store.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package horsepaste
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cockroachdb/pebble"
|
||||
)
|
||||
|
||||
// PebbleStore wraps a *pebble.DB with an implementation of the
|
||||
// Store interface, persisting games under a []byte(`/games/`)
|
||||
// key prefix.
|
||||
type PebbleStore struct {
|
||||
DB *pebble.DB
|
||||
}
|
||||
|
||||
// Restore loads all persisted games from storage.
|
||||
func (ps *PebbleStore) Restore() (map[string]*Game, error) {
|
||||
iter := ps.DB.NewIter(&pebble.IterOptions{
|
||||
LowerBound: []byte("/games/"),
|
||||
UpperBound: []byte(fmt.Sprintf("/games/%019d", math.MaxInt64)),
|
||||
})
|
||||
defer iter.Close()
|
||||
|
||||
games := make(map[string]*Game)
|
||||
for _ = iter.First(); iter.Valid(); iter.Next() {
|
||||
var g Game
|
||||
err := json.Unmarshal(iter.Value(), &g)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Unmarshal game: %w", err)
|
||||
}
|
||||
games[g.ID] = &g
|
||||
}
|
||||
if err := iter.Error(); err != nil {
|
||||
return nil, fmt.Errorf("restore iter: %w", err)
|
||||
}
|
||||
return games, nil
|
||||
}
|
||||
|
||||
// DeleteExpired deletes all games created before `expiry.`
|
||||
func (ps *PebbleStore) DeleteExpired(expiry time.Time) error {
|
||||
return ps.DB.DeleteRange(
|
||||
mkkey(0, ""),
|
||||
mkkey(expiry.Unix(), ""),
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// Save saves the game to persistent storage.
|
||||
func (ps *PebbleStore) Save(g *Game) error {
|
||||
k, v, err := gameKV(g)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trySave: %w", err)
|
||||
}
|
||||
|
||||
err = ps.DB.Set(k, v, &pebble.WriteOptions{Sync: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("db.Set: %w", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete removes a game from persistent storage.
|
||||
func (ps *PebbleStore) Delete(g *Game) error {
|
||||
k := mkkey(g.CreatedAt.Unix(), g.ID)
|
||||
err := ps.DB.Delete(k, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db.Delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CheckpointFile struct {
|
||||
Name string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Checkpoint returns a serialized represenation of the entire store.
|
||||
func (ps *PebbleStore) Checkpoint(w io.Writer) error {
|
||||
// Compact the entire key space. The database tends to be small and there
|
||||
// tends to be a significant number of obsolete keys, so this shouldn't be
|
||||
// too expensive but will reduce the number of bytes we need to send over
|
||||
// the network.
|
||||
err := ps.DB.Compact([]byte{}, []byte{0xFF, 0xFF, 0xFF, 0xFF}, true /* parallel */)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a Pebble checkpoint in a temporary directory.
|
||||
name, err := ioutil.TempDir("", "checkpoint")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(name); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(name)
|
||||
|
||||
err = ps.DB.Checkpoint(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write all the files in the checkpoint out over the network.
|
||||
gzipWriter := gzip.NewWriter(w)
|
||||
enc := gob.NewEncoder(gzipWriter)
|
||||
err = filepath.Walk(name, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relPath, err := filepath.Rel(name, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Checkpoint sending file %s (%d bytes)\n", relPath, len(b))
|
||||
return enc.Encode(CheckpointFile{
|
||||
Name: relPath,
|
||||
Data: b,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return gzipWriter.Close()
|
||||
}
|
||||
|
||||
func gameKV(g *Game) (key, value []byte, err error) {
|
||||
value, err = json.Marshal(g)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshaling GameState: %w", err)
|
||||
}
|
||||
return mkkey(g.CreatedAt.Unix(), g.ID), value, nil
|
||||
}
|
||||
|
||||
func mkkey(unixSecs int64, id string) []byte {
|
||||
// We could use a binary encoding for keys,
|
||||
// but it's not like we're storing that many
|
||||
// kv pairs. Ease of debugging is probably
|
||||
// more important.
|
||||
return []byte(fmt.Sprintf("/games/%019d/%q", unixSecs, id))
|
||||
}
|
||||
|
||||
type discardStore struct{}
|
||||
|
||||
func (ds discardStore) Save(*Game) error { return nil }
|
||||
func (ds discardStore) Delete(*Game) error { return nil }
|
||||
func (ds discardStore) Checkpoint(io.Writer) error { return nil }
|
||||
93
store_test.go
Normal file
93
store_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package horsepaste
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/cockroachdb/pebble"
|
||||
"github.com/jbowens/dictionary"
|
||||
"github.com/kr/pretty"
|
||||
)
|
||||
|
||||
var gameIDs, words []string
|
||||
|
||||
func init() {
|
||||
dictGameIDs, err := dictionary.Load("assets/game-id-words.txt")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
dictWords, err := dictionary.Load("assets/original.txt")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
gameIDs = dictGameIDs.Words()
|
||||
words = dictWords.Words()
|
||||
}
|
||||
|
||||
func randomGames(n int) map[string]*Game {
|
||||
games := make(map[string]*Game)
|
||||
for _, w := range gameIDs[:n] {
|
||||
games[w] = newGame(w, randomState(words), GameOptions{})
|
||||
}
|
||||
return games
|
||||
}
|
||||
|
||||
func TestPersist(t *testing.T) {
|
||||
dir, err := ioutil.TempDir("", "test-persist-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("pebble store dir: %s\n", dir)
|
||||
|
||||
var ps PebbleStore
|
||||
ps.DB, err = pebble.Open(dir, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
games := randomGames(5)
|
||||
for _, g := range games {
|
||||
if err := ps.Save(g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := ps.DB.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Re-open the DB.
|
||||
ps.DB, err = pebble.Open(dir, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restoredGames, err := ps.Restore()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.DB.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify the game states are the same.
|
||||
for id, g := range games {
|
||||
got, ok := restoredGames[id]
|
||||
if !ok {
|
||||
t.Fatalf("restoredGames[%q] doesn't exist", id)
|
||||
}
|
||||
if !reflect.DeepEqual(got.GameState, g.GameState) {
|
||||
t.Fatalf("%s: GameStates don't match: %s, %s",
|
||||
id, pretty.Sprint(got.GameState), pretty.Sprint(g.GameState))
|
||||
}
|
||||
if !reflect.DeepEqual(got.Words, g.Words) {
|
||||
t.Fatalf("%s: Words don't match: %s, %s",
|
||||
id, pretty.Sprint(got.Words), pretty.Sprint(g.Words))
|
||||
}
|
||||
if !reflect.DeepEqual(got.Layout, g.Layout) {
|
||||
t.Fatalf("%s: Layout don't match: %s, %s",
|
||||
id, pretty.Sprint(got.Layout), pretty.Sprint(g.Layout))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
64
wordset.go
Normal file
64
wordset.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package horsepaste
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type wordSetID [sha1.Size]byte
|
||||
|
||||
func (i wordSetID) String() string {
|
||||
return fmt.Sprintf("%x", i[:])
|
||||
}
|
||||
|
||||
type WordSets struct {
|
||||
mu sync.Mutex
|
||||
byID map[wordSetID][]string
|
||||
}
|
||||
|
||||
func (ws *WordSets) init() {
|
||||
if ws.byID == nil {
|
||||
ws.byID = make(map[wordSetID][]string)
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WordSets) Canonicalize(words []string) (wordSetID, []string, error) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
ws.init()
|
||||
|
||||
set := map[string]bool{}
|
||||
for _, w := range words {
|
||||
set[strings.TrimSpace(strings.ToUpper(w))] = true
|
||||
}
|
||||
if len(set) > 0 && len(set) < 25 {
|
||||
return wordSetID{}, nil, errors.New("need at least 25 words")
|
||||
}
|
||||
|
||||
words = words[:0]
|
||||
for w := range set {
|
||||
words = append(words, w)
|
||||
}
|
||||
sort.Strings(words)
|
||||
|
||||
// Calculate the word set ID, a hash of the canonicalized word set.
|
||||
h := sha1.New()
|
||||
for _, w := range words {
|
||||
io.WriteString(h, w)
|
||||
h.Write([]byte{0x00})
|
||||
}
|
||||
idBytes := h.Sum(nil)
|
||||
var id wordSetID
|
||||
copy(id[:], idBytes)
|
||||
|
||||
if interned, ok := ws.byID[id]; ok {
|
||||
return id, interned, nil
|
||||
}
|
||||
ws.byID[id] = words
|
||||
return id, words, nil
|
||||
}
|
||||
43
wordset_test.go
Normal file
43
wordset_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package horsepaste
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWordSetCanonicalize(t *testing.T) {
|
||||
b, err := ioutil.ReadFile("frontend/words.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var defaultWordsets map[string][]string
|
||||
err = json.NewDecoder(bytes.NewReader(b)).Decode(&defaultWordsets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
internedSets := map[string][]string{}
|
||||
|
||||
var ws WordSets
|
||||
for name, words := range defaultWordsets {
|
||||
id, interned, err := ws.Canonicalize(words)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("%s : %s\n", name, id)
|
||||
internedSets[name] = interned
|
||||
}
|
||||
|
||||
for name, words := range defaultWordsets {
|
||||
words2 := append([]string{}, words...)
|
||||
_, interned, err := ws.Canonicalize(words2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if &internedSets[name][0] != &interned[0] {
|
||||
t.Errorf("word set %q has different slice pointer 2nd canonicalization", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue