Compare commits

...

181 Commits

Author SHA1 Message Date
bel b89ed62036 dont ?say if hotword is a gm command 2023-04-19 18:49:34 -06:00
bel 67c93a9048 pageup, pagedown instead 2023-04-19 18:37:27 -06:00
bel 8eae7ae9a6 Merge branch 'master' of https://gogs.inhome.blapointe.com/bel/mayhem-party 2023-04-19 18:29:02 -06:00
bel ce32620940 add <=F24, >=F23 2023-04-19 18:28:39 -06:00
Bel LaPointe 24f4b6b8f5 v01 config has gm.hotwords.[].{call,args} and impl tap to link /gm/rpc/someoneSaidAlias to button pushes 2023-04-19 18:04:10 -06:00
zach-m 440191de0f Merge branch 'master' of https://gogs.inhome.blapointe.com/bel/mayhem-party 2023-04-10 18:55:35 -06:00
zach-m d5adc596ac adding "labels" 2023-04-10 18:55:26 -06:00
bel 41a39c40d0 oops debug logs 2023-04-10 18:40:43 -06:00
bel 9a38033b65 RAW_WS serves all static files 2023-04-10 18:38:38 -06:00
zach-m 6a4ad5ec36 adding labels 2023-04-10 18:26:47 -06:00
zach-m c2b8ab67f2 adding labels 2023-04-10 18:09:59 -06:00
Bel LaPointe 9418cecdf5 pre 2023-04-09 12:00:19 -06:00
Bel LaPointe fb5da88774 explicit ws debug 2023-04-09 11:57:45 -06:00
Bel LaPointe 39f6bc8ed9 RAW_WS_PROXY_URL=http://localhost:17071, RAW_WS=8080 to use web 2023-04-09 11:53:21 -06:00
Bel LaPointe f3cbfa1c48 html needs /proxy 2023-04-09 11:46:06 -06:00
Bel LaPointe 444245c0f5 sh 2023-04-09 11:37:46 -06:00
Bel LaPointe 52ee1e5083 mvp control via browser 2023-04-09 11:25:51 -06:00
bel 934158b7a3 y no ui 2023-04-02 11:15:51 -06:00
bel 87e63c27df go build 2023-04-02 11:13:28 -06:00
bel f98e417ba6 gr 2023-04-02 11:13:13 -06:00
bel d6a7ee3db0 grrr 2023-04-02 11:11:26 -06:00
bel b814dabfd3 gr 2023-04-02 11:10:03 -06:00
bel 0a91fc656d sh 2023-04-02 11:08:16 -06:00
bel 5c3341e260 condense status 2023-04-02 11:07:21 -06:00
bel 0903c01b9a verbose user feedback 2023-04-02 10:56:35 -06:00
bel 342e2eef93 alias formatting 2023-04-02 10:44:14 -06:00
bel b8b076450e debug 2023-04-02 10:33:20 -06:00
bel 3bb7cad554 debug 2023-04-02 10:28:12 -06:00
bel 44ec540db3 msg 2023-04-02 09:57:43 -06:00
bel e864f2a9f5 default /get includes broadcast message if no personal message and MySecretWord 2023-04-01 11:38:13 -06:00
bel 3c70e42819 broadcast isnt a user 2023-04-01 11:31:03 -06:00
bel 9de8c91544 export todo 2023-03-28 20:33:31 -06:00
Bel LaPointe 7f2e25458e backwards bool in main 2023-03-28 11:19:53 -06:00
bel 95810d3735 todo 2023-03-27 21:45:46 -06:00
bel df65b1ed07 mtodo 2023-03-27 21:43:44 -06:00
bel a36f07d0c1 accesslog 2023-03-27 21:39:07 -06:00
bel 09d9911293 log access 2023-03-27 21:21:03 -06:00
bel 60d391b7a4 flush config to disk every http 2023-03-27 21:05:38 -06:00
Bel LaPointe 60ed9c1269 todo 2023-03-27 17:55:42 -06:00
Bel LaPointe 13cf35bdd8 after voting clear votes 2023-03-27 17:55:19 -06:00
Bel LaPointe 79c90ac40c todo 2023-03-27 11:04:58 -06:00
Bel LaPointe dd1b053efa telemetry in bg thread and lossy so no block keyboad though i still find it pretty sus 2023-03-27 11:03:56 -06:00
Bel LaPointe f619fe9e1b some todo 2023-03-27 11:01:36 -06:00
Bel LaPointe 781bfb8a67 todo 2023-03-27 11:01:14 -06:00
Bel LaPointe 9ece270a13 rename State.GM THINGS to State.GM.Things 2023-03-27 11:00:40 -06:00
Bel LaPointe f647a03467 shuffle v01.config to split out state and meta 2023-03-27 10:54:02 -06:00
Bel LaPointe ecb719a97a test last GM RPC 2023-03-27 10:13:59 -06:00
Bel LaPointe 1738ce7d19 test a vote for someone saying themself 2023-03-27 10:11:17 -06:00
Bel LaPointe 354d07d6bf test serveGMVote 2023-03-27 09:45:57 -06:00
Bel LaPointe 39ab01525f shuffle 2023-03-27 09:35:11 -06:00
Bel LaPointe ceeeb8fe4b test fillNonPlayerAliases 2023-03-27 09:33:25 -06:00
Bel LaPointe d029d82366 impl,test fill non player aliases 2023-03-27 09:30:29 -06:00
Bel LaPointe 0435f7b3e8 test shuffle 2023-03-27 09:24:37 -06:00
Bel LaPointe 5ef0dde50d test no players/users and shuffle 2023-03-27 09:21:16 -06:00
Bel LaPointe bc3f0271e7 test happy 1:1 shuffle 2023-03-27 08:26:55 -06:00
Bel LaPointe e2d7c4a908 test unhappy swap 2023-03-27 08:23:37 -06:00
Bel LaPointe c744704b63 test happy swap 2023-03-27 08:21:03 -06:00
Bel LaPointe 213fd555e4 test happy shuffle 2023-03-27 08:20:50 -06:00
Bel LaPointe dd41028aab test /gm/rpc/broadcastSomeoneSaidAlias 2023-03-27 07:26:40 -06:00
Bel LaPointe 8ff1c2fab4 test nonzero player on status 2023-03-27 06:58:59 -06:00
Bel LaPointe 1f7b222b9c test nonzero status 2023-03-27 06:58:21 -06:00
Bel LaPointe 1842023224 struct 2023-03-27 06:53:46 -06:00
Bel LaPointe 45b873f462 test status 2023-03-27 06:51:15 -06:00
Bel LaPointe 88a78c489f test status 2023-03-27 06:50:31 -06:00
Bel LaPointe 8314bdc457 impl public status endpoint 2023-03-27 06:24:41 -06:00
Bel LaPointe a6a9b177e9 whoops dont sleep use some ctx 2023-03-27 06:19:39 -06:00
Bel LaPointe f649862dd4 todo 2023-03-27 06:18:52 -06:00
Bel LaPointe 85804d6f84 lock on every http req BUT LOCKS ON TELEMETRY SO BEWARE 2023-03-27 06:18:32 -06:00
Bel LaPointe f14871218d stub 2023-03-27 06:14:52 -06:00
Bel LaPointe 26f052d981 workin on gm rpc 2023-03-27 06:13:26 -06:00
bel 2d4cb394de wip 2023-03-26 23:35:10 -06:00
bel 163bf2b405 serve GET /config 2023-03-26 22:39:47 -06:00
bel e968ce17ce stash last lag and ts for each user 2023-03-26 22:19:07 -06:00
bel f07e67b3fd export host.d to -venue repo 2023-03-26 16:24:08 -06:00
bel fbf4849517 todo 2023-03-26 14:54:27 -06:00
bel 804ce02407 todo 2023-03-26 14:28:48 -06:00
bel 4a86d2b6ca update readme for STT setting Quiet mode 2023-03-26 14:27:42 -06:00
bel c663b1a12c expose PATCH /config 2023-03-26 14:17:33 -06:00
bel af42db6803 v01.config accepts and applies json patch 2023-03-26 10:20:19 -06:00
bel f9dc4cff9f todo 2023-03-26 10:08:04 -06:00
bel 37050f3d87 test quiet mode 2023-03-26 10:06:22 -06:00
bel 74717609ec v01.yaml has .quiet=true to cause all button pushes to become releases 2023-03-26 10:00:10 -06:00
bel 24ae45896f todo 2023-03-26 09:57:03 -06:00
bel 8b29648c50 add hotwords file for stt 2023-03-26 09:55:50 -06:00
bel 1eba008efe readme order 2023-03-26 09:49:51 -06:00
bel d48c545030 update host.README for tts and stt integrated 2023-03-26 09:47:19 -06:00
bel 323ca466ad update configs in host.d for tts 2023-03-26 09:29:02 -06:00
bel 67e504ced6 accept say in headers for more length 2023-03-26 09:25:48 -06:00
bel ad967d5047 test a longer input and its k 2023-03-26 09:23:20 -06:00
bel 8fd0067ad1 block while tts speaking for singleton 2023-03-26 09:18:31 -06:00
bel 43566be7ae ?say=XYZ to TTS 2023-03-26 09:13:24 -06:00
bel cb8b254cbb ?say=XYZ to TTS 2023-03-26 09:12:05 -06:00
bel 340ca1d2f5 go test ok 2023-03-26 08:48:46 -06:00
bel 02c49852c0 todo 2023-03-26 08:48:00 -06:00
bel 02c9dce1b3 split server 2023-03-26 08:46:31 -06:00
bel a3650642ca todo 2023-03-26 08:44:30 -06:00
bel fbded57807 rename 2023-03-26 08:42:40 -06:00
bel 44cb05487e split out message, config 2023-03-26 08:42:09 -06:00
bel e1e2ce3eec split out xform 2023-03-26 08:39:50 -06:00
bel 4c7f444887 more splitting v01 2023-03-26 08:38:24 -06:00
bel 0311fc56a3 split v01 into its own pkg 2023-03-26 08:37:13 -06:00
bel 9902684990 todo 2023-03-26 08:34:48 -06:00
bel 967e66bdb3 todo 2023-03-25 23:16:48 -06:00
bel ff21bfb8b3 todo 2023-03-25 23:08:59 -06:00
bel c153636e24 locks 2023-03-25 23:04:00 -06:00
bel efe4adf129 k no deadlock 2023-03-25 22:57:12 -06:00
bel 802266e500 remove wrapToParse dependence 2023-03-25 22:54:23 -06:00
bel 373d8be1a0 split button and parse packages 2023-03-25 22:52:09 -06:00
bel bd5654128e accept PUT /broadcast to change the broadcast message 2023-03-25 22:37:19 -06:00
bel 9073658e12 update readme with linux build windows 2023-03-25 19:21:46 -06:00
bel 7df4d09553 update rusty-pipes for feedback 2023-03-25 14:51:19 -06:00
bel 1ad60189f4 todo 2023-03-25 11:29:54 -06:00
bel 766c77b00a todo 2023-03-25 11:28:41 -06:00
bel bcdf545188 todo 2023-03-25 11:28:30 -06:00
bel 3264d9ad55 can send messages back to specific and ALL viewers 2023-03-25 11:27:49 -06:00
bel 3f35f7f936 manual test w rusty-pipe v0.1.3 ok 2023-03-25 11:01:38 -06:00
bel 0cddc33ac6 update host mp env file 2023-03-25 10:59:07 -06:00
bel a1a12b1873 input Getenvs to FlagXYZ 2023-03-25 10:58:13 -06:00
bel ae1e32391c refresh neither leaks wraps, allows 2 of the same at once, nor closes raws 2023-03-25 10:50:39 -06:00
bel 97cc3ae151 refresh users global ch 2023-03-25 10:25:11 -06:00
bel 2113252e2d no lookup env 2023-03-25 10:20:05 -06:00
bel 2cae3c6d28 dont do raw.New, instead add raw.Raw.Refresh explicit 2023-03-25 10:17:36 -06:00
bel de261ae400 todo 2023-03-25 10:15:23 -06:00
bel 3dd0a557d4 add ctx to v01 2023-03-25 10:15:14 -06:00
bel 51ae1b27b4 on refresh, recreate raw too, because i dont wanna be leaking by not Closing on refresh 2023-03-25 10:13:25 -06:00
bel 50e89492cf todo 2023-03-25 09:12:44 -06:00
bel 3d9ea1296c external test on player transformation 2023-03-25 09:12:10 -06:00
bel db69f76aa0 unit tests are good and v01cfg transforms input if players and user in players 2023-03-25 09:06:43 -06:00
bel 0ee3a8b6e8 todo 2023-03-25 00:44:30 -06:00
bel b379f1d82c sample cfg file 2023-03-25 00:43:51 -06:00
bel c83f9d8700 load v01 config 2023-03-25 00:30:13 -06:00
bel 6289222b69 todo 2023-03-25 00:13:22 -06:00
bel 607a65e22e if debugging then print lag to stderr 2023-03-25 00:11:12 -06:00
bel 6bbb297c59 todo 2023-03-25 00:06:32 -06:00
bel 95866f7df0 upgrade host.config.mp.env to v01 2023-03-25 00:02:08 -06:00
bel aaa949cc2a upgrade host.configs.rusty-pipe to v01 2023-03-25 00:01:35 -06:00
bel ed2b7b7cb9 rename v1 to v01 for git tag 2023-03-24 22:27:56 -06:00
bel 1ef3afd647 whitespace 2023-03-24 22:26:34 -06:00
bel 2746051a2a typing 2023-03-24 22:24:08 -06:00
bel 610aef4f7e rebuild parser on refresh 2023-03-24 22:15:36 -06:00
bel a9ca58f154 v1 complete 2023-03-24 22:10:37 -06:00
bel 7182ab387f test button.plaintext parser 2023-03-24 21:38:06 -06:00
bel 0e46f6e122 todo 2023-03-24 21:17:01 -06:00
bel 01777c8c3e clean shutdown with udp 2023-03-24 21:14:27 -06:00
bel aa16b66332 udp in bg thread 2023-03-24 21:02:47 -06:00
bel 2af373aed7 integrate button.Parser 2023-03-24 20:52:44 -06:00
bel 896f5e9c92 wrap accepts button.Parser 2023-03-24 20:50:58 -06:00
bel b319ed7e6d split wrap protocol parsing into input.button 2023-03-24 20:25:15 -06:00
bel 9990273b19 protocol should be pkg 2023-03-24 20:05:55 -06:00
bel ea7f2d8932 change testdata env to new RAW_ and WRAP_ 2023-03-24 19:53:55 -06:00
bel 38b00e55b0 split src/devices/input into src/devices/input/{raw,wrap} 2023-03-24 19:51:38 -06:00
bel ab673a81f0 update host config based on mayhem-party having its own udp server now 2023-03-24 18:55:44 -06:00
Bel LaPointe 287b9c7b4e udp input except no clean shutdown 2023-03-24 18:48:54 -06:00
bel 126f5ab60a har we go 2023-03-24 18:35:16 -06:00
bel 3c19f984a9 add required field to rusty configs 2023-03-24 15:39:40 -06:00
bel cf3b93464a update readme for compile 2023-03-24 15:29:33 -06:00
bel edcea37148 drop .[] chars as they dont work on linux and macos for me out of the box 2023-03-24 15:28:17 -06:00
Bel LaPointe b4e4de82ae almost 2023-03-24 15:13:17 -06:00
Bel LaPointe cdfcfe8fd0 make rotate.sh first class citizen for receiving signal 2023-03-24 14:44:47 -06:00
Bel LaPointe bf677856a2 script generating player offset files 2023-03-24 14:40:39 -06:00
Bel LaPointe b9d76d5e8f create mvp files to run mayhem party without remap to stdout when hosting 2023-03-24 14:16:05 -06:00
Bel LaPointe d292a830a1 set up players 1..5 rusty-pipe.yamls 2023-03-24 14:09:31 -06:00
Bel LaPointe 745175210c mv README#host to host.d 2023-03-24 14:01:44 -06:00
Bel LaPointe 6536daee7f support alpha, numeric, f0..10, punctuation, math keys 2023-03-24 13:49:12 -06:00
Bel LaPointe 9ce50f2622 todo 2023-03-24 13:39:29 -06:00
Bel LaPointe ea0bb5d365 revert back to case sensitive because you cant hold A and a at the same time 2023-03-24 13:39:17 -06:00
Bel LaPointe 20488d2be8 no wait shift means sideaffecting 2023-03-24 13:37:53 -06:00
Bel LaPointe 7b7486cc93 keys support case 2023-03-24 13:30:46 -06:00
Bel LaPointe e5a668b691 howtohost 2023-03-24 13:05:45 -06:00
Bel LaPointe c298bb0dfd todo 2023-03-24 12:57:31 -06:00
Bel LaPointe adabc4eb98 input.New refactor and test 2023-03-24 12:05:50 -06:00
Bel LaPointe 6e1bfc177d fix random weighted char because %sum can never get last value of sum 2023-03-24 11:51:29 -06:00
Bel LaPointe e491cc5cbc refresher input 2023-03-24 11:46:55 -06:00
bel 37d02f0f52 debugs 2023-03-23 21:05:30 -06:00
bel e832085fc2 todo 2023-03-23 20:55:58 -06:00
bel 8e92c9a6d6 keyboard supports !a to indicate release a 2023-03-23 20:39:57 -06:00
Bel LaPointe 1fc6d71db6 $INPUT_BUFFERED_STICKY_DURATION 2023-03-23 17:00:49 -06:00
Bel LaPointe 4f48ee805f whoops nums bitwised 2023-03-23 16:14:25 -06:00
Bel LaPointe f9ec874491 support numbers as well 2023-03-23 16:10:31 -06:00
Bel LaPointe 32c186e1e2 input ignores newline chars 2023-03-23 15:53:01 -06:00
Bel LaPointe 17b2891f9a readme 2023-03-02 15:38:59 -07:00
48 changed files with 2846 additions and 118 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/mayhem-party
**/*.sw*

View File

@ -9,3 +9,14 @@ Think Dug's Twitch Chat Plays
* multiplayer engine
* server; https://github.com/LizardByte/Sunshine
* client; https://moonlight-stream.org/
## DONE
* input
* random from weighted file
* buffered
* remapped from file
* output
* to keyboard
* to stderr

17
go.mod
View File

@ -3,6 +3,19 @@ module mayhem-party
go 1.19
require (
github.com/go-yaml/yaml v2.1.0+incompatible // indirect
github.com/micmonay/keybd_event v1.1.1 // indirect
github.com/faiface/beep v1.1.0
github.com/go-yaml/yaml v2.1.0+incompatible
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/hajimehoshi/oto v0.7.1 // indirect
github.com/micmonay/keybd_event v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 // indirect
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 // indirect
)

45
go.sum
View File

@ -1,4 +1,49 @@
github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c=
github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE=
github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM=
github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI=
github.com/hajimehoshi/oto v0.7.1 h1:I7maFPz5MBCwiutOrz++DLdbr4rTzBsbBuV2VpgU9kk=
github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos=
github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk=
github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0=
github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU=
github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA=
github.com/micmonay/keybd_event v1.1.1 h1:rv7omwXWYL9Lgf3PUq6uBgJI2k1yGkL/GD6dxc6nmSs=
github.com/micmonay/keybd_event v1.1.1/go.mod h1:CGMWMDNgsfPljzrAWoybUOSKafQPZpv+rLigt2LzNGI=
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=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 h1:idBdZTd9UioThJp8KpM/rTSinK/ChZFBE43/WtIy8zg=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 h1:vyLBGJPIl9ZYbcQFM2USFmJBK6KI+t+z6jL0lbwjrnc=
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10=
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

View File

@ -11,6 +11,14 @@ import (
func main() {
ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
defer can()
defer func() {
if err := recover(); err != nil {
log.Println("panic:", err)
panic(err)
}
}()
if err := src.Main(ctx); err != nil && ctx.Err() == nil {
panic(err)
}

View File

@ -0,0 +1,12 @@
package button
import "fmt"
type Button struct {
Char byte
Down bool
}
func (button Button) String() string {
return fmt.Sprintf("%c:%v", button.Char, button.Down)
}

View File

@ -2,28 +2,20 @@ package input
import (
"context"
"os"
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/parse"
"mayhem-party/src/device/input/raw"
"mayhem-party/src/device/input/wrap"
)
type Input interface {
Read() []Button
Read() []button.Button
Close()
}
func New(ctx context.Context) Input {
foo := randomCharFromRange('a', 'g')
if p, ok := os.LookupEnv("INPUT_RANDOM_WEIGHT_FILE"); ok && len(p) > 0 {
foo = randomCharFromWeightFile(p)
}
var result Input = NewRandom(foo)
if os.Getenv("INPUT_KEYBOARD") == "true" {
result = NewKeyboard()
}
if os.Getenv("INPUT_BUFFERED") == "true" {
result = NewBuffered(ctx, result)
}
if p, ok := os.LookupEnv("INPUT_REMAP_FILE"); ok && len(p) > 0 {
result = NewRemapFromFile(result, p)
}
return result
src := raw.New(ctx)
return wrap.New(ctx, func() wrap.Wrap {
return parse.New(ctx, src)
})
}

View File

@ -3,6 +3,8 @@ package input_test
import (
"context"
"mayhem-party/src/device/input"
"mayhem-party/src/device/input/raw"
"mayhem-party/src/device/input/wrap"
"os"
"path"
"testing"
@ -30,11 +32,11 @@ func TestNewRemapped(t *testing.T) {
t.Fatal(err)
}
os.Setenv("INPUT_REMAP_FILE", remap)
os.Setenv("INPUT_RANDOM_WEIGHT_FILE", rand)
wrap.FlagRemapFile = remap
raw.FlagRawRandomWeightFile = rand
t.Cleanup(func() {
os.Unsetenv("INPUT_REMAP_FILE")
os.Unsetenv("INPUT_RANDOM_WEIGHT_FILE")
wrap.FlagRemapFile = ""
raw.FlagRawRandomWeightFile = ""
})
r := input.New(context.Background())
@ -50,9 +52,9 @@ func TestNewRemapped(t *testing.T) {
}
func TestNewBuffered(t *testing.T) {
os.Setenv("INPUT_BUFFERED", "true")
wrap.FlagBuffered = true
t.Cleanup(func() {
os.Unsetenv("INPUT_BUFFERED")
wrap.FlagBuffered = false
})
r := input.New(context.Background())
@ -71,9 +73,9 @@ func TestNewRandomWeightFile(t *testing.T) {
t.Fatal(err)
}
os.Setenv("INPUT_RANDOM_WEIGHT_FILE", p)
raw.FlagRawRandomWeightFile = p
t.Cleanup(func() {
os.Unsetenv("INPUT_RANDOM_WEIGHT_FILE")
raw.FlagRawRandomWeightFile = ""
})
r := input.New(context.Background())

View File

@ -1,10 +0,0 @@
package input
import "testing"
func TestInput(t *testing.T) {
var _ Input = &Random{}
var _ Input = Keyboard{}
var _ Input = &Buffered{}
var _ Input = Remap{}
}

View File

@ -0,0 +1,26 @@
package parse
import (
"context"
"mayhem-party/src/device/input/button"
v01 "mayhem-party/src/device/input/parse/v01"
"mayhem-party/src/device/input/raw"
"os"
)
var (
FlagParseV01 = os.Getenv("PARSE_V01") == "true"
)
type Parser interface {
Read() []button.Button
Close()
CloseWrap() raw.Raw
}
func New(ctx context.Context, src raw.Raw) Parser {
if FlagParseV01 {
return v01.NewV01(ctx, src)
}
return NewPlaintext(src)
}

View File

@ -0,0 +1,12 @@
package parse_test
import (
"mayhem-party/src/device/input/parse"
v01 "mayhem-party/src/device/input/parse/v01"
"testing"
)
func TestParser(t *testing.T) {
var _ parse.Parser = parse.Plaintext{}
var _ parse.Parser = &v01.V01{}
}

View File

@ -0,0 +1,48 @@
package parse
import (
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/raw"
"os"
)
var (
FlagParsePlaintextRelease = os.Getenv("PARSE_PLAINTEXT_RELEASE")
)
type Plaintext struct {
src raw.Raw
release byte
}
func NewPlaintext(src raw.Raw) Plaintext {
releaseChar := byte('!')
if FlagParsePlaintextRelease != "" {
releaseChar = byte(FlagParsePlaintextRelease[0])
}
return Plaintext{
src: src,
release: releaseChar,
}
}
func (p Plaintext) Close() { p.src.Close() }
func (p Plaintext) CloseWrap() raw.Raw { return p.src }
func (p Plaintext) Read() []button.Button {
b := p.src.Read()
buttons := make([]button.Button, 0, len(b))
down := true
for i := range b {
if b[i] == p.release {
down = false
} else {
if b[i] != '\n' {
buttons = append(buttons, button.Button{Char: b[i], Down: down})
}
down = true
}
}
return buttons
}

View File

@ -0,0 +1,30 @@
package parse_test
import (
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/parse"
"testing"
)
func TestPlaintext(t *testing.T) {
src := constSrc("c!b")
p := parse.NewPlaintext(src)
got := p.Read()
if len(got) != 2 {
t.Fatal(len(got))
}
if got[0] != (button.Button{Char: 'c', Down: true}) {
t.Error(got[0])
}
if got[1] != (button.Button{Char: 'b', Down: false}) {
t.Error(got[1])
}
}
type constSrc string
func (c constSrc) Close() {}
func (c constSrc) Read() []byte {
return []byte(c)
}

View File

@ -0,0 +1,84 @@
package v01
import (
"encoding/json"
"sync"
patch "github.com/evanphx/json-patch/v5"
)
type (
config struct {
lock *sync.Mutex
Feedback configFeedback
Users map[string]configUser
Players []configPlayer
Quiet bool
Broadcast configBroadcast
GM configGM
}
configGM struct {
Hotwords map[string]configGMHotword
}
configGMHotword struct {
Call string
Args []string
}
configBroadcast struct {
Message string
}
configFeedback struct {
Addr string
TTSURL string
}
configUser struct {
Meta configUserMeta
State configUserState
}
configUserMeta struct {
LastTSMS int64
LastLag int64
}
configUserState struct {
Player int
Message string
GM configUserStateGM
}
configUserStateGM struct {
Alias string
LastAlias string
Vote string
}
configPlayer struct {
Transformation transformation
}
)
func (cfg config) WithPatch(v interface{}) config {
cfg.lock.Lock()
defer cfg.lock.Unlock()
originalData, _ := json.Marshal(cfg)
patchData, _ := json.Marshal(v)
patcher, err := patch.DecodePatch(patchData)
if err != nil {
return cfg
}
patchedData, err := patcher.Apply(originalData)
if err != nil {
return cfg
}
var patched config
if err := json.Unmarshal(patchedData, &patched); err != nil {
return cfg
}
return patched
}

View File

@ -0,0 +1,83 @@
package v01
import (
"fmt"
"sync"
"testing"
)
func TestConfigPatch(t *testing.T) {
cases := map[string]struct {
cfg config
patch interface{}
want config
}{
"nil patch": {
cfg: config{Quiet: true},
patch: nil,
want: config{Quiet: true},
},
"[] patch": {
cfg: config{Quiet: true},
patch: []interface{}{},
want: config{Quiet: true},
},
"set fake field": {
cfg: config{Quiet: true},
patch: []interface{}{
map[string]interface{}{"op": "add", "path": "/Fake", "value": true},
},
want: config{Quiet: true},
},
"remove field": {
cfg: config{Quiet: true},
patch: []interface{}{
map[string]interface{}{"op": "remove", "path": "/Quiet"},
},
want: config{Quiet: false},
},
"replace field with valid": {
cfg: config{Quiet: true},
patch: []interface{}{
map[string]interface{}{"op": "replace", "path": "/Quiet", "value": false},
},
want: config{Quiet: false},
},
"replace field with invalid": {
cfg: config{Quiet: true},
patch: []interface{}{
map[string]interface{}{"op": "replace", "path": "/Quiet", "value": "teehee"},
},
want: config{Quiet: true},
},
"test and noop": {
cfg: config{Quiet: true},
patch: []interface{}{
map[string]interface{}{"op": "test", "path": "/Quiet", "value": false},
map[string]interface{}{"op": "replace", "path": "/Quiet", "value": false},
},
want: config{Quiet: true},
},
"test and apply": {
cfg: config{Quiet: true},
patch: []interface{}{
map[string]interface{}{"op": "test", "path": "/Quiet", "value": true},
map[string]interface{}{"op": "replace", "path": "/Quiet", "value": false},
},
want: config{Quiet: false},
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
c.cfg.lock = &sync.Mutex{}
got := c.cfg.WithPatch(c.patch)
got.lock = nil
c.want.lock = nil
if fmt.Sprintf("%+v", got) != fmt.Sprintf("%+v", c.want) {
t.Errorf("(%+v).Patch(%+v) want %+v, got %+v", c.cfg, c.patch, c.want, got)
}
})
}
}

View File

@ -0,0 +1,27 @@
package v01
import (
"log"
"mayhem-party/src/device/input/button"
)
type message struct {
T int64
U string
Y string
N string
}
func (msg message) buttons() []button.Button {
buttons := make([]button.Button, len(msg.Y)+len(msg.N))
for i := range msg.Y {
buttons[i] = button.Button{Char: msg.Y[i], Down: true}
}
for i := range msg.N {
buttons[len(msg.Y)+i] = button.Button{Char: msg.N[i], Down: false}
}
if FlagDebug {
log.Printf("%+v", msg)
}
return buttons
}

View File

@ -0,0 +1,407 @@
package v01
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand"
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/wrap"
"net/http"
"os"
"strings"
"sync"
"syscall"
"time"
"gopkg.in/yaml.v2"
)
func (v01 *V01) listen() {
if v01.cfg.Feedback.Addr == "" {
return
}
v01._listen()
}
func (v01 *V01) _listen() {
mutex := &sync.Mutex{}
s := &http.Server{
Addr: v01.cfg.Feedback.Addr,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
defer func() { log.Printf("%vms | %s %s", time.Since(start).Milliseconds(), r.Method, r.URL) }()
v01.cfg.lock.Lock()
defer v01.cfg.lock.Unlock()
if r.Method == http.MethodGet {
mutex.Lock()
defer mutex.Unlock()
} else {
mutex.Lock()
defer mutex.Unlock()
}
v01.ServeHTTP(w, r)
v01.stashConfig() // TODO
}),
}
go func() {
<-v01.ctx.Done()
log.Println("closing v01 server")
s.Close()
}()
log.Println("starting v01 server")
if err := s.ListenAndServe(); err != nil && v01.ctx.Err() == nil {
log.Println("err with v01 server", err)
panic(err)
}
}
func (v01 *V01) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(v01.ctx)
v01.serveHTTP(w, r)
v01.serveGlobalQueries(r)
}
func (v01 *V01) serveHTTP(w http.ResponseWriter, r *http.Request) {
switch strings.Split(r.URL.Path[1:], "/")[0] {
case "":
v01.getUserFeedback(w, r)
case "broadcast":
v01.servePutBroadcast(w, r)
case "config":
v01.serveConfig(w, r)
case "gm":
v01.serveGM(w, r)
}
}
func (v01 *V01) getUserFeedback(w http.ResponseWriter, r *http.Request) {
user := v01.cfg.Users[r.URL.Query().Get("user")]
msg := user.State.Message
if msg == "" {
msg = v01.cfg.Broadcast.Message
}
alias := user.State.GM.Alias
if alias == "" {
alias = user.State.GM.LastAlias
}
if alias != "" {
msg = fmt.Sprintf("%s (Your secret word is '%s'. Make **someone else** say it!)", msg, alias)
}
w.Write([]byte(msg + "\n\n"))
v01.serveGMStatus(w)
if v01.cfg.Quiet {
w.Write([]byte("\n\n"))
v01.serveGMVoteRead(w)
}
}
func (v01 *V01) servePutBroadcast(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
v01.servePutBroadcastValue(string(b))
}
func (v01 *V01) servePutBroadcastValue(v string) {
v01.cfg.Broadcast.Message = v
}
func (v01 *V01) serveConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
v01.serveGetConfig(w, r)
} else {
v01.servePatchConfig(w, r)
}
}
func (v01 *V01) serveGetConfig(w http.ResponseWriter, r *http.Request) {
b, _ := json.Marshal(v01.cfg)
w.Write(b)
}
func (v01 *V01) servePatchConfig(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
var v []interface{}
if err := json.Unmarshal(b, &v); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
v01.cfg = v01.cfg.WithPatch(v)
if err := v01.stashConfig(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (v01 *V01) stashConfig() error {
if b, err := yaml.Marshal(v01.cfg); err == nil && FlagParseV01Config != "" {
if err := os.WriteFile(FlagParseV01Config, b, os.ModePerm); err != nil {
return err
}
} else if err != nil {
return err
}
return nil
}
func (v01 *V01) serveGlobalQueries(r *http.Request) {
v01.serveGlobalQuerySay(r)
v01.serveGlobalQueryRefresh(r)
}
func (v01 *V01) serveGlobalQuerySay(r *http.Request) {
text := r.URL.Query().Get("say")
if text == "" {
text = r.Header.Get("say")
}
if text == "" {
return
}
go v01.tts(text)
}
func (v01 *V01) serveGlobalQueryRefresh(r *http.Request) {
if _, ok := r.URL.Query()["refresh"]; !ok {
return
}
select {
case wrap.ChSigUsr1 <- syscall.SIGUSR1:
default:
}
}
func (v01 *V01) serveGM(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/gm/rpc/status":
v01.serveGMStatus(w)
case "/gm/rpc/broadcastSomeoneSaidAlias":
v01.serveGMSomeoneSaid(w, r)
case "/gm/rpc/fillNonPlayerAliases":
v01.serveGMFillNonPlayerAliases(w, r)
case "/gm/rpc/vote":
v01.serveGMVote(w, r)
case "/gm/rpc/elect":
v01.serveGMElect(w, r)
case "/gm/rpc/shuffle":
v01.serveGMShuffle(r)
case "/gm/rpc/swap":
if errCode, err := v01.serveGMSwap(r.URL.Query().Get("a"), r.URL.Query().Get("b")); err != nil {
http.Error(w, err.Error(), errCode)
return
}
default:
http.NotFound(w, r)
return
}
}
func (v01 *V01) serveGMStatus(w io.Writer) {
users := map[string]string{}
for k, v := range v01.cfg.Users {
result := ""
if v.State.Player > 0 {
result += fmt.Sprintf("Player %v ", v.State.Player)
}
if ms := time.Duration(v.Meta.LastLag) * time.Millisecond; v.Meta.LastLag > 0 && ms < time.Minute {
result += fmt.Sprintf("%s ", ms.String())
}
if result == "" {
result = "..."
}
users[k] = result
}
b, _ := yaml.Marshal(map[string]interface{}{
"Players": len(v01.cfg.Players),
"Users": users,
})
w.Write(b)
}
func (v01 *V01) serveGMSomeoneSaid(w http.ResponseWriter, r *http.Request) {
if gmHotword, ok := v01.cfg.GM.Hotwords[r.URL.Query().Get("message")]; ok {
v01.serveGMSomeoneSaidGMHotword(w, r, gmHotword)
}
v01.serveGMSomeoneSaidAlias(w, r)
}
func (v01 *V01) serveGMSomeoneSaidGMHotword(w http.ResponseWriter, r *http.Request, gmHotword configGMHotword) {
switch gmHotword.Call {
case "tap":
args := append([]string{}, gmHotword.Args...)
if len(args) < 1 || len(args[0]) < 1 {
return
}
btn := args[0][0]
go func() {
v01.alt <- []button.Button{button.Button{Down: true, Char: btn}}
v01.alt <- []button.Button{button.Button{Down: false, Char: btn}}
}()
r.URL.RawQuery = ""
default:
http.NotFound(w, r)
}
}
func (v01 *V01) serveGMSomeoneSaidAlias(w http.ResponseWriter, r *http.Request) {
v01.cfg.Quiet = true
for k, v := range v01.cfg.Users {
v.State.GM.LastAlias = v.State.GM.Alias
v.State.GM.Alias = ""
v01.cfg.Users[k] = v
}
v01.servePutBroadcastValue(fmt.Sprintf("<<SOMEONE SAID %q>>", strings.ToUpper(r.URL.Query().Get("message"))))
}
func (v01 *V01) serveGMFillNonPlayerAliases(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
var pool []string
yaml.Unmarshal(b, &pool)
n := 0
for _, v := range v01.cfg.Users {
if v.State.Player == 0 {
n += 1
}
}
if n < 1 {
w.WriteHeader(http.StatusNoContent)
return
}
if len(pool) < n {
http.Error(w, fmt.Sprintf("request body must contain a list of %v options", n), http.StatusBadRequest)
return
}
for i := 0; i < 100; i++ {
a, b := rand.Int()%len(pool), rand.Int()%len(pool)
pool[a], pool[b] = pool[b], pool[a]
}
i := 0
for k, v := range v01.cfg.Users {
if v.State.Player == 0 {
v.State.GM.Alias = pool[i]
v01.cfg.Users[k] = v
i += 1
}
}
}
func (v01 *V01) serveGMElect(w http.ResponseWriter, r *http.Request) {
alias := r.URL.Query().Get("alias")
aliasWinner := ""
votes := map[string]int{}
for k, v := range v01.cfg.Users {
votes[v.State.GM.Vote] = votes[v.State.GM.Vote] + 1
if v.State.GM.LastAlias == alias {
aliasWinner = k
}
}
if aliasWinner == "" {
http.Error(w, "who is "+alias+"?", http.StatusBadRequest)
return
}
threshold := 0.1 + float64(len(votes))/2.0
winner := ""
for k, v := range votes {
if float64(v) > threshold {
winner = k
}
}
if winner == "" {
v01.serveGMShuffle(r)
} else if _, err := v01.serveGMSwap(winner, aliasWinner); err != nil {
v01.serveGMShuffle(r)
}
for k, v := range v01.cfg.Users {
v.State.GM.Vote = ""
v01.cfg.Users[k] = v
}
yaml.NewEncoder(w).Encode(votes)
}
func (v01 *V01) serveGMVote(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("payload") {
case "":
v01.serveGMVoteRead(w)
default:
v01.serveGMVoteWrite(w, r)
}
}
func (v01 *V01) serveGMVoteRead(w io.Writer) {
counts := map[string]string{}
for k, v := range v01.cfg.Users {
if v.State.GM.Vote != "" {
counts[k] = "voted"
} else {
counts[k] = "voting"
}
}
yaml.NewEncoder(w).Encode(counts)
}
func (v01 *V01) serveGMVoteWrite(w http.ResponseWriter, r *http.Request) {
voter := r.URL.Query().Get("user")
candidate := r.URL.Query().Get("payload")
v, ok := v01.cfg.Users[voter]
if _, ok2 := v01.cfg.Users[candidate]; !ok || !ok2 {
http.Error(w, "bad voter/candidate", http.StatusBadRequest)
return
}
v.State.GM.Vote = candidate
v01.cfg.Users[voter] = v
}
func (v01 *V01) serveGMShuffle(r *http.Request) {
poolSize := len(v01.cfg.Users)
if altSize := len(v01.cfg.Players); altSize > poolSize {
poolSize = altSize
}
pool := make([]int, poolSize)
if poolSize > 0 {
for i := range v01.cfg.Players {
pool[i] = i + 1
}
for i := 0; i < 30; i++ {
l := rand.Int() % poolSize
r := rand.Int() % poolSize
pool[l], pool[r] = pool[r], pool[l]
}
}
i := 0
msg := []string{}
for k, v := range v01.cfg.Users {
v.State.Player = pool[i]
v01.cfg.Users[k] = v
if pool[i] > 0 {
msg = append(msg, fmt.Sprintf("%s is now player %v", k, v.State.Player))
}
i += 1
}
v01.servePutBroadcastValue(strings.Join(msg, ", "))
v01.cfg.Quiet = false
}
func (v01 *V01) serveGMSwap(userA, userB string) (int, error) {
if userA == userB {
return http.StatusConflict, errors.New("/spiderman-pointing")
}
_, okA := v01.cfg.Users[userA]
_, okB := v01.cfg.Users[userB]
if !okA || !okB {
return http.StatusBadRequest, errors.New("who dat?")
}
a := v01.cfg.Users[userA]
b := v01.cfg.Users[userB]
a.State.Player, b.State.Player = b.State.Player, a.State.Player
v01.cfg.Users[userA] = a
v01.cfg.Users[userB] = b
v01.cfg.Quiet = false
v01.servePutBroadcastValue(fmt.Sprintf(`%s is now player %v and %s is now player %v`, userA, a.State.Player, userB, b.State.Player))
return http.StatusOK, nil
}

View File

@ -0,0 +1,592 @@
package v01
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path"
"strconv"
"strings"
"sync"
"testing"
"time"
"gopkg.in/yaml.v2"
)
func TestPatchConfig(t *testing.T) {
dir := t.TempDir()
p := path.Join(dir, t.Name()+".yaml")
cases := map[string]struct {
was config
patch string
want config
}{
"replace entire doc": {
was: config{
Feedback: configFeedback{Addr: "a", TTSURL: "a"},
Users: map[string]configUser{"a": configUser{State: configUserState{Player: 1, Message: "a"}}},
Players: []configPlayer{configPlayer{Transformation: transformation{"a": "a"}}},
Quiet: true,
},
patch: `[{"op": "replace", "path": "", "value": {
"Feedback": {"Addr": "b", "TTSURL": "b"},
"Users": {"b": {"State":{"Player": 2, "Message": "b"}}},
"Players": [{"Transformation": {"b": "b"}}],
"Quiet": false
}}]`,
want: config{
Feedback: configFeedback{Addr: "b", TTSURL: "b"},
Users: map[string]configUser{"b": configUser{State: configUserState{Player: 2, Message: "b"}}},
Players: []configPlayer{configPlayer{Transformation: transformation{"b": "b"}}},
Quiet: false,
},
},
}
for name, d := range cases {
c := d
for _, usesdisk := range []bool{false, true} {
t.Run(fmt.Sprintf("%s disk=%v", name, usesdisk), func(t *testing.T) {
b, _ := yaml.Marshal(c.was)
os.WriteFile(p, b, os.ModePerm)
FlagParseV01Config = ""
if usesdisk {
FlagParseV01Config = p
}
v01 := &V01{cfg: c.was}
v01.cfg.lock = &sync.Mutex{}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPatch, "/config", strings.NewReader(c.patch))
v01.servePatchConfig(w, r)
if fmt.Sprintf("%+v", c.want) != fmt.Sprintf("%+v", v01.cfg) {
t.Errorf("want \n\t%+v, got \n\t%+v", c.want, v01.cfg)
}
if usesdisk {
b, _ := os.ReadFile(p)
var got config
yaml.Unmarshal(b, &got)
if fmt.Sprintf("%+v", c.want) != fmt.Sprintf("%+v", v01.cfg) {
t.Errorf("want \n\t%+v, got \n\t%+v", c.want, v01.cfg)
}
}
})
}
}
}
func TestServeGM(t *testing.T) {
ctx, can := context.WithCancel(context.Background())
defer can()
do := func(v01 *V01, path, body string, method ...string) *httptest.ResponseRecorder {
m := http.MethodPost
if len(method) > 0 {
m = method[0]
}
w := httptest.NewRecorder()
r := httptest.NewRequest(m, path, strings.NewReader(body))
v01.ServeHTTP(w, r)
return w
}
t.Run("status", func(t *testing.T) {
v01 := NewV01(ctx, nil)
var result struct {
Players int `yaml:"Players"`
Users map[string]string `yaml:"Users"`
}
t.Run("empty", func(t *testing.T) {
resp := do(v01, "/gm/rpc/status", "")
if resp.Code != http.StatusOK {
t.Error(resp.Code)
}
t.Log(string(resp.Body.Bytes()))
if err := yaml.Unmarshal(resp.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Players != 0 {
t.Error(result.Players)
}
if len(result.Users) != 0 {
t.Error(result.Users)
}
})
t.Run("full", func(t *testing.T) {
v01.cfg.Players = []configPlayer{
{},
{},
{},
{},
}
v01.cfg.Users = map[string]configUser{
"bel": configUser{
State: configUserState{Player: 3},
Meta: configUserMeta{
LastTSMS: time.Now().Add(-1*time.Minute).UnixNano() / int64(time.Millisecond),
LastLag: int64(time.Second / time.Millisecond),
},
},
"zach": configUser{},
"chase": configUser{},
"mason": configUser{},
"nat": configUser{},
"roxy": configUser{},
"bill": configUser{},
}
resp := do(v01, "/gm/rpc/status", "")
if resp.Code != http.StatusOK {
t.Error(resp.Code)
}
t.Log(string(resp.Body.Bytes()))
if err := yaml.Unmarshal(resp.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Players != 4 {
t.Error(result.Players)
}
if len(result.Users) != 7 {
t.Error(result.Users)
}
if result.Users["bel"] == "" || result.Users["bel"] == "..." {
t.Error(result.Users["bel"])
}
})
})
t.Run("broadcastSomeoneSaidAlias to hotword tap", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.GM = configGM{
Hotwords: map[string]configGMHotword{
"hotword": configGMHotword{
Call: "tap",
Args: []string{"a"},
},
},
}
do(v01, "/gm/rpc/broadcastSomeoneSaidAlias?message=hotword", "")
for i := 0; i < 2; i++ {
select {
case btn := <-v01.alt:
if len(btn) != 1 {
t.Error(btn)
} else if btn[0].Down != (i == 0) {
t.Error(btn[0])
} else if btn[0].Char != 'a' {
t.Error(btn[0].Char)
}
case <-time.After(time.Second):
t.Fatal("nothing in alt")
}
}
do(v01, "/gm/rpc/broadcastSomeoneSaidAlias?message=hotword", "")
time.Sleep(time.Millisecond * 150)
if got := v01.Read(); len(got) != 1 || !got[0].Down || got[0].Char != 'a' {
t.Error(got)
} else if got := v01.Read(); len(got) != 1 || got[0].Down || got[0].Char != 'a' {
t.Error(got)
}
})
t.Run("broadcastSomeoneSaidAlias", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Quiet = false
v01.cfg.Users = map[string]configUser{
"bel": configUser{State: configUserState{
GM: configUserStateGM{
Alias: "driver",
},
Message: "if someone else says 'driver', then you get to play",
}},
}
v01.cfg.Broadcast.Message = ":)"
do(v01, "/gm/rpc/broadcastSomeoneSaidAlias", "")
if !v01.cfg.Quiet {
t.Error(v01.cfg.Quiet)
}
if v := v01.cfg.Users["bel"]; v.State.GM.Alias != "" {
t.Error(v.State.GM.Alias)
} else if v.State.GM.LastAlias != "driver" {
t.Error(v.State.GM.LastAlias)
}
if bc := v01.cfg.Broadcast.Message; bc == ":)" {
t.Error(bc)
}
})
t.Run("fillNonPlayerAliases", func(t *testing.T) {
t.Run("empty", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Users = nil
resp := do(v01, "/gm/rpc/fillNonPlayerAliases", "[qt]")
if resp.Code != http.StatusNoContent {
t.Error(resp.Code)
}
})
t.Run("not enough", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Users = map[string]configUser{
"zach": configUser{State: configUserState{Player: 0}},
}
resp := do(v01, "/gm/rpc/fillNonPlayerAliases", "[]")
if resp.Code != http.StatusBadRequest {
t.Error(resp.Code)
}
})
t.Run("happy", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Users = map[string]configUser{
"bel": configUser{State: configUserState{Player: 1}},
"zach": configUser{State: configUserState{Player: 0}},
}
do(v01, "/gm/rpc/fillNonPlayerAliases", "[qt]")
if v := v01.cfg.Users["bel"]; v.State.GM.Alias != "" {
t.Error(v.State.GM.Alias)
} else if v.State.Player != 1 {
t.Error(v.State.Player)
}
if v := v01.cfg.Users["zach"]; v.State.GM.Alias != "qt" {
t.Error(v.State.GM.Alias)
} else if v.State.Player != 0 {
t.Error(v.State.Player)
}
})
})
t.Run("vote", func(t *testing.T) {
type result map[string]string
t.Run("cast bad vote", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Users = map[string]configUser{"bel": {}}
resp := do(v01, "/gm/rpc/vote?user=bel&payload=?", "")
if resp.Code != http.StatusBadRequest {
t.Error(resp)
}
if v01.cfg.Users["bel"].State.Message != "" {
t.Error(v01.cfg.Users["bel"].State.Message)
}
})
t.Run("cast vote", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Users = map[string]configUser{"bel": {}, "zach": {}}
do(v01, "/gm/rpc/vote?user=bel&payload=zach", "")
if v01.cfg.Users["bel"].State.GM.Vote != "zach" {
t.Error(v01.cfg.Users["bel"].State.GM.Vote)
}
})
t.Run("get non vote", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Users = map[string]configUser{"bel": {}}
resp := do(v01, "/gm/rpc/vote", "", "GET")
var result result
if err := yaml.Unmarshal(resp.Body.Bytes(), &result); err != nil {
t.Error(err)
}
if len(result) != 1 {
t.Error(result)
}
if result["bel"] != "voting" {
t.Error(result)
}
t.Logf("%+v", result)
})
t.Run("get mid vote", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Users = map[string]configUser{"bel": {State: configUserState{GM: configUserStateGM{Vote: "zach"}, Message: "driver"}}}
resp := do(v01, "/gm/rpc/vote", "", "GET")
var result result
if err := yaml.Unmarshal(resp.Body.Bytes(), &result); err != nil {
t.Error(err)
}
if len(result) != 1 {
t.Error(result)
}
if result["bel"] != "voted" {
t.Error(result)
}
t.Logf("%+v", result)
})
t.Run("get empty", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Users = nil
resp := do(v01, "/gm/rpc/vote", "", "GET")
var result result
if err := yaml.Unmarshal(resp.Body.Bytes(), &result); err != nil {
t.Error(err)
}
if len(result) != 0 {
t.Error(result)
}
t.Logf("%+v", result)
})
})
t.Run("elect", func(t *testing.T) {
type result map[string]int
t.Run("happy", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Users = map[string]configUser{
"bel": configUser{State: configUserState{GM: configUserStateGM{Vote: "zach", LastAlias: "driver"}, Player: 1}},
"zach": configUser{State: configUserState{GM: configUserStateGM{Vote: "bel", LastAlias: "pizza"}}},
"bill": configUser{State: configUserState{GM: configUserStateGM{Vote: "bel"}, Player: 2}},
}
resp := do(v01, "/gm/rpc/elect?alias=pizza", "")
var result result
if err := yaml.Unmarshal(resp.Body.Bytes(), &result); err != nil {
t.Errorf("%s => %v", resp.Body.Bytes(), err)
}
if len(result) != 2 {
t.Error(result)
} else if result["bel"] != 2 {
t.Error(result)
} else if result["zach"] != 1 {
t.Error(result)
}
if v01.cfg.Users["bel"].State.Player != 0 {
t.Error(v01.cfg.Users["bel"].State.Player)
} else if v01.cfg.Users["zach"].State.Player != 1 {
t.Error(v01.cfg.Users["zach"].State.Player)
}
if v01.cfg.Broadcast.Message != `bel is now player 0 and zach is now player 1` {
t.Error(v01.cfg.Broadcast)
}
})
t.Run("self", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Players = []configPlayer{{}}
v01.cfg.Users = map[string]configUser{
"bel": configUser{State: configUserState{GM: configUserStateGM{Vote: "zach", LastAlias: "driver"}, Player: 1}},
"zach": configUser{State: configUserState{GM: configUserStateGM{Vote: "bel"}}},
"bill": configUser{State: configUserState{GM: configUserStateGM{Vote: "bel"}}},
}
resp := do(v01, "/gm/rpc/elect?alias=driver", "")
var result result
if err := yaml.Unmarshal(resp.Body.Bytes(), &result); err != nil {
t.Error(err)
}
if len(result) != 2 {
t.Error(result)
} else if result["bel"] != 2 {
t.Error(result)
} else if result["zach"] != 1 {
t.Error(result)
}
if !strings.HasSuffix(v01.cfg.Broadcast.Message, `is now player 1`) || strings.Contains(v01.cfg.Broadcast.Message, ",") {
t.Error(v01.cfg.Broadcast.Message)
}
assignments := map[int]int{}
for _, v := range v01.cfg.Users {
assignments[v.State.Player] = assignments[v.State.Player] + 1
}
if len(assignments) != 2 {
t.Error(assignments)
} else if assignments[0] != 2 {
t.Error(assignments[0])
} else if assignments[1] != 1 {
t.Error(assignments[1])
}
})
t.Run("tie", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Players = []configPlayer{{}}
v01.cfg.Users = map[string]configUser{
"bel": configUser{State: configUserState{GM: configUserStateGM{Vote: "zach", LastAlias: "driver"}, Player: 1}},
"zach": configUser{State: configUserState{GM: configUserStateGM{Vote: "bel", LastAlias: "pizza"}}},
}
resp := do(v01, "/gm/rpc/elect?alias=pizza", "")
var result result
if err := yaml.Unmarshal(resp.Body.Bytes(), &result); err != nil {
t.Error(err)
}
if len(result) != 2 {
t.Error(result)
} else if result["bel"] != 1 {
t.Error(result)
} else if result["zach"] != 1 {
t.Error(result)
}
if bc := v01.cfg.Broadcast.Message; !strings.HasSuffix(bc, `is now player 1`) || strings.Contains(bc, ",") {
t.Error(bc)
}
assignments := map[int]int{}
for _, v := range v01.cfg.Users {
assignments[v.State.Player] = assignments[v.State.Player] + 1
}
if len(assignments) != 2 {
t.Error(assignments)
} else if assignments[0] != 1 {
t.Error(assignments[0])
} else if assignments[1] != 1 {
t.Error(assignments[1])
}
})
})
t.Run("shuffle", func(t *testing.T) {
t.Run("many 2u 2p", func(t *testing.T) {
v01 := NewV01(ctx, nil)
for i := 0; i < 100; i++ {
v01.cfg.Quiet = true
v01.cfg.Users = map[string]configUser{
"bel": configUser{State: configUserState{Player: 1}},
"zach": configUser{State: configUserState{Player: 2}},
}
v01.cfg.Players = []configPlayer{{}, {}}
do(v01, "/gm/rpc/shuffle", "")
if v01.cfg.Quiet {
t.Error(v01.cfg.Quiet)
}
if len(v01.cfg.Users) != 2 {
t.Error(v01.cfg.Users)
} else if len(v01.cfg.Players) != 2 {
t.Error(v01.cfg.Users)
} else if bp := v01.cfg.Users["bel"].State.Player; bp != 1 && bp != 2 {
t.Error(bp)
} else if zp := v01.cfg.Users["zach"].State.Player; zp != 1 && zp != 2 {
t.Error(zp)
} else if bp == zp {
t.Error(bp, zp)
}
}
})
cases := map[string]struct {
users int
usersAssigned int
players int
}{
"empty": {},
"just users": {users: 2},
"just players": {players: 2},
"2 unassigned users and 2 players": {users: 2, players: 2},
"2 users and 2 players": {users: 2, usersAssigned: 2, players: 2},
"1 users and 2 players": {users: 1, usersAssigned: 1, players: 2},
"1 unassigned users and 2 players": {users: 1, players: 2},
"4 players for 7 users 0 assigned": {users: 7, players: 4},
"4 players for 7 users 4 assigned": {users: 7, players: 4, usersAssigned: 4},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Quiet = true
v01.cfg.Users = map[string]configUser{}
for i := 0; i < c.users; i++ {
v01.cfg.Users[strconv.Itoa(i)] = configUser{}
if i < c.usersAssigned {
v01.cfg.Users[strconv.Itoa(i)] = configUser{State: configUserState{Player: i}}
}
}
v01.cfg.Players = make([]configPlayer, c.players)
do(v01, "/gm/rpc/shuffle", "")
if v01.cfg.Quiet {
t.Error(v01.cfg.Quiet)
}
if len(v01.cfg.Users) != c.users {
t.Error(v01.cfg.Users)
} else if len(v01.cfg.Players) != c.players {
t.Error(v01.cfg.Users)
}
for i := 0; i < c.users; i++ {
if _, ok := v01.cfg.Users[strconv.Itoa(i)]; !ok {
t.Error(i)
}
}
assignments := map[int]int{}
for _, v := range v01.cfg.Users {
if v.State.Player > 0 {
assignments[v.State.Player] = assignments[v.State.Player] + 1
}
}
lesser := c.users
if c.players < lesser {
lesser = c.players
}
if len(assignments) != lesser {
t.Error(assignments)
}
for _, v := range assignments {
if v != 1 {
t.Error(v)
}
}
})
}
})
t.Run("swap", func(t *testing.T) {
t.Run("self", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Quiet = true
v01.cfg.Users = map[string]configUser{
"bel": configUser{State: configUserState{Player: 1}},
}
resp := do(v01, "/gm/rpc/swap?a=bel&b=bel", "")
if resp.Code != http.StatusConflict {
t.Error(resp.Code)
}
if !v01.cfg.Quiet {
t.Error(v01.cfg.Quiet)
}
})
t.Run("who", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Quiet = true
resp := do(v01, "/gm/rpc/swap?a=bel", "")
if resp.Code != http.StatusBadRequest {
t.Error(resp.Code)
}
if !v01.cfg.Quiet {
t.Error(v01.cfg.Quiet)
}
})
t.Run("happy", func(t *testing.T) {
v01 := NewV01(ctx, nil)
v01.cfg.Quiet = true
v01.cfg.Users = map[string]configUser{
"bel": configUser{State: configUserState{Player: 1}},
"zach": configUser{State: configUserState{Player: 2}},
}
resp := do(v01, "/gm/rpc/swap?a=bel&b=zach", "")
if resp.Code != http.StatusOK {
t.Error(resp.Code)
}
if v01.cfg.Quiet {
t.Error(v01.cfg.Quiet)
}
if v01.cfg.Users["bel"].State.Player != 2 {
t.Error(v01.cfg.Users["bel"])
} else if v01.cfg.Users["zach"].State.Player != 1 {
t.Error(v01.cfg.Users["zach"])
}
})
})
t.Run("404", func(t *testing.T) {
v01 := NewV01(ctx, nil)
resp := do(v01, "/gm/teehee", "")
if resp.Code != http.StatusNotFound {
t.Error(resp.Code)
}
})
}

View File

@ -0,0 +1,28 @@
feedback:
addr: :17071
ttsurl: http://localhost:15002
users:
bel:
meta:
lasttsms: 1681062770999
lastlag: 12
state:
player: 0
message: hi
gm:
alias: ""
lastalias: ""
vote: ""
players:
- transformation: {}
quiet: false
broadcast:
message: hi
gm:
hotwords:
coin:
call: tap
args: ['!']
star:
call: tap
args: ['?']

View File

@ -0,0 +1,14 @@
package v01
type (
transformation map[string]string
)
func (t transformation) pipe(s string) string {
for i := range s {
if v := t[s[i:i+1]]; v != "" {
s = s[:i] + v[:1] + s[i+1:]
}
}
return s
}

View File

@ -0,0 +1,76 @@
package v01
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sync"
"time"
"github.com/faiface/beep"
"github.com/faiface/beep/effects"
"github.com/faiface/beep/speaker"
"github.com/faiface/beep/wav"
)
var (
ttsLock = &sync.RWMutex{}
)
func (v01 *V01) tts(text string) {
if err := v01._tts(text); err != nil {
log.Printf("failed to tts: %s: %v", text, err)
}
}
func (v01 *V01) _tts(text string) error {
if v01.cfg.Feedback.TTSURL == "" {
return nil
}
url, err := url.Parse(v01.cfg.Feedback.TTSURL)
if err != nil {
return err
}
if len(url.Path) < 2 {
url.Path = "/api/tts"
}
q := url.Query()
if q.Get("voice") == "" {
q.Set("voice", "en-us/glados-glow_tts")
}
if q.Get("lengthScale") == "" {
q.Set("lengthScale", "1")
}
q.Set("text", text)
url.RawQuery = q.Encode()
resp, err := http.Get(url.String())
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "audio/wav" {
return fmt.Errorf("failed to call ttsurl: (%d) %s", resp.StatusCode, b)
}
decoder, format, err := wav.Decode(bytes.NewReader(b))
if err != nil {
return err
}
ttsLock.Lock()
defer ttsLock.Unlock()
speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/30))
speaker.Play(&effects.Volume{Streamer: beep.ResampleRatio(4, 1, &beep.Ctrl{Streamer: beep.Loop(1, decoder)})})
duration := time.Duration(decoder.Len()) * format.SampleRate.D(1)
select {
case <-v01.ctx.Done():
case <-time.After(duration):
}
return nil
}

View File

@ -0,0 +1,132 @@
package v01
import (
"context"
"encoding/json"
"io/ioutil"
"log"
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/raw"
"os"
"sync"
"time"
"gopkg.in/yaml.v2"
)
var (
FlagDebug = os.Getenv("DEBUG") == "true"
FlagParseV01Config = os.Getenv("V01_CONFIG")
)
type (
V01 struct {
ctx context.Context
can context.CancelFunc
src raw.Raw
cfg config
telemetryc chan message
alt chan []button.Button
}
)
func NewV01(ctx context.Context, src raw.Raw) *V01 {
var cfg config
cfg.lock = &sync.Mutex{}
b, _ := ioutil.ReadFile(FlagParseV01Config)
yaml.Unmarshal(b, &cfg)
ctx, can := context.WithCancel(ctx)
result := &V01{
ctx: ctx,
can: can,
src: src,
cfg: cfg,
telemetryc: make(chan message),
alt: make(chan []button.Button, 2),
}
go result.listen()
go result.dotelemetry()
return result
}
func (v01 *V01) CloseWrap() raw.Raw {
v01.can()
return v01.src
}
func (v01 *V01) Close() {
v01.can()
v01.src.Close()
}
func (v01 *V01) Read() []button.Button {
select {
case alt := <-v01.alt:
return alt
default:
}
line := v01.src.Read()
var msg message
if err := json.Unmarshal(line, &msg); err != nil {
log.Printf("%v: %s", err, line)
}
v01.telemetry(msg)
buttons := v01.transform(msg).buttons()
if v01.cfg.Quiet {
for i := range buttons {
buttons[i].Down = false
}
}
return buttons
}
func (v01 *V01) dotelemetry() {
for {
select {
case <-v01.ctx.Done():
return
case msg := <-v01.telemetryc:
v01._telemetry(msg)
}
}
}
func (v01 *V01) telemetry(msg message) {
select {
case v01.telemetryc <- msg:
default:
}
}
func (v01 *V01) _telemetry(msg message) {
// TODO oof
v01.cfg.lock.Lock()
defer v01.cfg.lock.Unlock()
if v01.cfg.Users == nil {
v01.cfg.Users = map[string]configUser{}
}
u := v01.cfg.Users[msg.U]
u.Meta.LastLag = time.Now().UnixNano()/int64(time.Millisecond) - msg.T
u.Meta.LastTSMS = msg.T
if FlagDebug {
log.Printf("%s|%dms", msg.U, u.Meta.LastLag)
}
v01.cfg.Users[msg.U] = u
}
func (v01 *V01) transform(msg message) message {
if len(v01.cfg.Players) == 0 {
return msg
}
user := v01.cfg.Users[msg.U]
if user.State.Player < 1 {
msg.Y = ""
msg.N = ""
return msg
}
player := v01.cfg.Players[user.State.Player-1]
msg.Y = player.Transformation.pipe(msg.Y)
msg.N = player.Transformation.pipe(msg.N)
return msg
}

View File

@ -0,0 +1,192 @@
package v01_test
import (
"context"
"fmt"
"io"
"mayhem-party/src/device/input/button"
v01 "mayhem-party/src/device/input/parse/v01"
"net/http"
"os"
"path"
"strings"
"testing"
"time"
)
func TestV01(t *testing.T) {
src := constSrc(fmt.Sprintf(`{"T":%v,"U":"bel","Y":"abc","N":"cde"}`, time.Now().UnixNano()/int64(time.Millisecond)-50))
t.Logf("(%v) %s", len(src), src.Read())
v01 := v01.NewV01(context.Background(), src)
defer v01.Close()
got := v01.Read()
want := []button.Button{
{Down: true, Char: 'a'},
{Down: true, Char: 'b'},
{Down: true, Char: 'c'},
{Down: false, Char: 'c'},
{Down: false, Char: 'd'},
{Down: false, Char: 'e'},
}
if len(got) != len(want) {
t.Fatal(len(want), len(got))
}
for i := range got {
if got[i] != want[i] {
t.Errorf("[%d] want %+v got %+v", i, want[i], got[i])
}
}
}
func TestV01WithCfg(t *testing.T) {
d := t.TempDir()
p := path.Join(d, "cfg.yaml")
os.WriteFile(p, []byte(`
users:
bel:
state:
player: 2
players:
- transformation:
w: t
- transformation:
w: i
`), os.ModePerm)
v01.FlagParseV01Config = p
t.Run("unknown user ignored", func(t *testing.T) {
v01 := v01.NewV01(context.Background(), constSrc(`{"U":"qt","Y":"w"}`))
defer v01.Close()
got := v01.Read()
if len(got) != 0 {
t.Error(got)
}
})
t.Run("player2", func(t *testing.T) {
v01 := v01.NewV01(context.Background(), constSrc(`{"U":"bel","Y":"w","N":"w"}`))
defer v01.Close()
got := v01.Read()
if len(got) != 2 {
t.Error(got)
}
if got[0] != (button.Button{Char: 'i', Down: true}) {
t.Error(got[0])
}
if got[1] != (button.Button{Char: 'i', Down: false}) {
t.Error(got[1])
}
})
}
func TestV01Feedback(t *testing.T) {
d := t.TempDir()
p := path.Join(d, "cfg.yaml")
os.WriteFile(p, []byte(`
feedback:
addr: :27071
ttsurl: http://localhost:15002
users:
bel:
state:
player: 2
message: to bel
broadcast:
message: to everyone
players:
- transformation:
w: t
- transformation:
w: i
`), os.ModePerm)
v01.FlagParseV01Config = p
ctx, can := context.WithCancel(context.Background())
defer can()
v01 := v01.NewV01(ctx, constSrc(`{"U":"qt","Y":"w"}`))
defer v01.Close()
for {
time.Sleep(time.Millisecond * 100)
resp, err := http.Get("http://localhost:27071?user=bel")
if err != nil {
continue
}
resp.Body.Close()
break
}
t.Run("specific user", func(t *testing.T) {
resp, err := http.Get("http://localhost:27071?user=bel")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if !strings.HasPrefix(string(b), "to bel") {
t.Error(string(b))
}
})
t.Run("broadcast", func(t *testing.T) {
resp, err := http.Get("http://localhost:27071")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if !strings.HasPrefix(string(b), "to everyone") {
t.Error(string(b))
}
})
t.Run("change broadcast", func(t *testing.T) {
want := `my new broadcast`
r, _ := http.NewRequest(http.MethodPut, "http://localhost:27071/broadcast", strings.NewReader(want))
resp, err := http.DefaultClient.Do(r)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
resp, err = http.Get("http://localhost:27071")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if !strings.HasPrefix(string(b), want) {
t.Error(string(b))
}
})
t.Run("tts", func(t *testing.T) {
if os.Getenv("INTEGRATION_TTS") != "true" {
t.Skip("$INTEGRATION_TTS is not true")
}
for i := 0; i < 2; i++ {
resp, err := http.Get("http://localhost:27071/?say=hello%20world")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
time.Sleep(time.Millisecond * 2500)
r, _ := http.NewRequest(http.MethodGet, "http://localhost:27071", nil)
r.Header.Set("say", "No, HTTP does not define any limit. However most web servers do limit size of headers they accept. For example in Apache default limit is 8KB, in IIS it's 16K. Server will return 413 Entity Too Large error if headers size exceeds that limit.")
resp, err := http.DefaultClient.Do(r)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
time.Sleep(time.Millisecond * 8500)
})
}
type constSrc string
func (c constSrc) Close() {}
func (c constSrc) Read() []byte {
return []byte(c)
}

View File

@ -0,0 +1,92 @@
package v01
import (
"context"
"testing"
)
func TestV01TransformationPipe(t *testing.T) {
cases := map[string]struct {
input string
xform map[string]string
want string
}{
"empty input": {
xform: map[string]string{"a": "bc"},
},
"empty xform": {
input: "aa",
want: "aa",
},
"all": {
input: "aa",
xform: map[string]string{"a": "cc"},
want: "cc",
},
"last": {
input: "ba",
xform: map[string]string{"a": "cc"},
want: "bc",
},
"first": {
input: "ab",
xform: map[string]string{"a": "cc"},
want: "cb",
},
"noop": {
input: "bb",
xform: map[string]string{"a": "bc"},
want: "bb",
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
got := transformation(c.xform).pipe(c.input)
if got != c.want {
t.Errorf("%+v(%s) want %s got %s", c.xform, c.input, c.want, got)
}
})
}
}
func TestV01Quiet(t *testing.T) {
ctx, can := context.WithCancel(context.Background())
defer can()
v01 := NewV01(ctx, constSrc(`{"Y":"a", "N":"b"}`))
v01.cfg.Quiet = false
if got := v01.Read(); len(got) != 2 {
t.Error(len(got))
} else if got[0].Char != 'a' {
t.Error(got[0].Char)
} else if got[0].Down != true {
t.Error(got[0].Down)
} else if got[1].Char != 'b' {
t.Error(got[1].Char)
} else if got[1].Down != false {
t.Error(got[1].Down)
}
v01.cfg.Quiet = true
if got := v01.Read(); len(got) != 2 {
t.Error(len(got))
} else if got[0].Char != 'a' {
t.Error(got[0].Char)
} else if got[0].Down != false {
t.Error(got[0].Down)
} else if got[1].Char != 'b' {
t.Error(got[1].Char)
} else if got[1].Down != false {
t.Error(got[1].Down)
}
}
type constSrc string
func (c constSrc) Close() {}
func (c constSrc) Read() []byte {
return []byte(c)
}

View File

@ -1,7 +1,8 @@
package input
package raw
import (
"io"
"log"
"os"
"os/exec"
"runtime"
@ -43,16 +44,14 @@ func (kb Keyboard) Close() {
}
}
func (kb Keyboard) Read() []Button {
func (kb Keyboard) Read() []byte {
b := make([]byte, 5)
n, err := os.Stdin.Read(b)
if err != nil && err != io.EOF {
panic(err)
}
result := make([]Button, n)
for i := range result {
result[i] = Button{Char: b[i], Down: true}
if FlagDebug {
log.Printf("raw.Keyboard.Read() %s", b[:n])
}
return result
return b[:n]
}

View File

@ -0,0 +1,45 @@
// a key map of allowed keys
var allowedKeys = {
37: 'left',
38: 'up',
39: 'right',
40: 'down',
65: 'a',
66: 'b'
};
// the 'official' Konami Code sequence
var konamiCode = ['up', 'up', 'down', 'down', 'left', 'right', 'left', 'right', 'b', 'a'];
// a variable to remember the 'position' the user has reached so far.
var konamiCodePosition = 0;
// add keydown event listener
document.addEventListener('keydown', function(e) {
// get the value of the key code from the key map
var key = allowedKeys[e.keyCode];
// get the value of the required key from the konami code
var requiredKey = konamiCode[konamiCodePosition];
// compare the key with the required key
if (key == requiredKey) {
// move to the next key in the konami code sequence
konamiCodePosition++;
// if the last key is reached, activate cheats
if (konamiCodePosition == konamiCode.length) {
showSecrets();
konamiCodePosition = 0;
}
} else {
konamiCodePosition = 0;
}
});
function showSecrets() {
var element = document.getElementById("konami")
element.style = "display:block"
var e = new Event("onKonami")
element.dispatchEvent(new Event("onKonami"))
}

View File

@ -0,0 +1,26 @@
function onYouTubeIframeAPIReady() {
var player;
player = new YT.Player('konami', {
videoId: 'V4oJ62xrFZo', // 👈 video id.
width: 560,
height: 316,
playerVars: {
'autoplay': 1,
'controls': 1,
'showinfo': 0,
'modestbranding': 0,
'loop': 1,
'fs': 0,
'cc_load_policty': 0,
'iv_load_policy': 3
},
events: {
'onReady': function (e) {
e.target.setVolume(33); // For max value, set value to 100.
document.getElementById("konami").addEventListener("onKonami", () => {e.target.playVideo()})
}
}
});
}

View File

@ -0,0 +1,172 @@
<!DOCTYPE html>
<html>
<header>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
<script src="konami.js"></script>
<script src="lowerVolume.js"></script>
<script async src="https://www.youtube.com/iframe_api"></script>
<script>
function formsay(message) {
console.log(`say '${message}'`)
http("GET", `/proxy?user=${document.getElementById("user").value}&say=${message}`, noopcallback, null)
}
function formsend(message) {
console.log(`send '${message}'`)
http("GET", `/proxy/gm/rpc/vote?user=${document.getElementById("user").value}&payload=${message}`, noopcallback, null)
}
function http(method, remote, callback, body) {
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
callback(xmlhttp.responseText, xmlhttp.status)
}
};
xmlhttp.open(method, remote, true);
if (typeof body == "undefined") {
body = null
}
xmlhttp.send(body);
}
function noopcallback(responseBody, responseStatus) {
}
setInterval(() => {
http("GET", `/proxy?user=${document.getElementById("user").value}`, (b, s) => {
if (s != 200)
return
document.getElementById("ntfy").innerHTML = `<pre>${b}</pre>`
}, null)
}, 1500)
</script>
</header>
<body>
<div>
<form>
<h3>WHO AM I</h3>
<select id="user">
<option>bel</option>
<option>zach</option>
<option>chase</option>
<option>mason</option>
<option>nat</option>
<option>roxy</option>
<option>bill</option>
</select>
</form>
<div>
<form action="" onsubmit="formsay(this.children.say.value); return false;" style="display: inline-block;">
<h3>SAY</h3>
<input type="text" name="say">
<input type="submit" value="say">
</form>
<form action="" onsubmit="formsend(this.children.send.value); return false;" style="display: inline-block;">
<h3>SEND</h3>
<select name="send">
<option>bel</option>
<option>zach</option>
<option>chase</option>
<option>mason</option>
<option>nat</option>
<option>roxy</option>
<option>bill</option>
</select>
<input type="submit" value="send">
</form>
</div>
<details>
<summary>CONTROLS</summary>
<form id="controls">
<div style="display: flex; flex-wrap: wrap;">
<div>
<label for="input-up">Up</label>
<input id="input-up" type="text" maxLength=1 value="w" name="w" placeholder="up" onchange="recontrol()">
<label for="input-down">Down</label>
<input id="input-down" type="text" maxLength=1 value="s" name="s" placeholder="down" onchange="recontrol()">
<label for="input-left">Left</label>
<input id="input-left" type="text" maxLength=1 value="a" name="a" placeholder="left" onchange="recontrol()">
<label for="input-right">Right</label>
<input id="input-right" type="text" maxLength=1 value="d" name="d" placeholder="right" onchange="recontrol()">
</div>
<div>
<label for="input-start">Start</label>
<input id="input-start" type="text" maxLength=1 value="5" name="5" placeholder="start" onchange="recontrol()">
<label for="input-left-bumper">Left Bumper</label>
<input id="input-left-bumper" type="text" maxLength=1 value="q" name="q" placeholder="l" onchange="recontrol()">
<label for="input-right-bumper">Right Bumper</label>
<input id="input-right-bumper" type="text" maxLength=1 value="e" name="e" placeholder="r" onchange="recontrol()">
</div>
<div>
<label for="input-a">A</label>
<input id="input-a" type="text" maxLength=1 value="1" name="1" placeholder="a" onchange="recontrol()">
<label for="input-b">B</label>
<input id="input-b" type="text" maxLength=1 value="2" name="2" placeholder="b" onchange="recontrol()">
<label for="input-x">X</label>
<input id="input-x" type="text" maxLength=1 value="3" name="3" placeholder="x" onchange="recontrol()">
<label for="input-y">Y</label>
<input id="input-y" type="text" maxLength=1 value="4" name="4" placeholder="y" onchange="recontrol()">
</div>
</div>
</form>
</details>
</div>
<div id="ntfy"></div>
<div id="ws"></div>
<div id="konami" style="display:none"></div>
</body>
<footer>
<script>
var socket = new WebSocket("ws://"+window.location.host+"/api/ws")
function nosend(data) {
}
function dosend(data) {
console.log(JSON.stringify(data))
socket.send(JSON.stringify(data))
}
send = nosend
socket.addEventListener("open", (_) => {
console.log("ws open")
send = dosend
})
socket.addEventListener("message", (event) => console.log("ws recv:", event.data))
socket.addEventListener("close", (event) => console.log("ws closed"))
keys = {}
document.addEventListener('keydown', (event) => {
var name = controls[event.key]
if (!name)
return
if (keys[name])
return
keys[name] = true
sendKeys(name, "")
})
document.addEventListener('keyup', (event) => {
var name = controls[event.key]
if (!name)
return
keys[name] = false
sendKeys("", name)
})
function sendKeys(y, n) {
send({
T: new Date().getTime(),
U: document.getElementById("user").value,
Y: y,
N: n,
})
}
var controls = {}
function recontrol() {
for (var k in controls)
controls[k] = false
for (var e of document.getElementById("controls").getElementsByTagName("input"))
controls[e.value] = e.name
}
recontrol()
</script>
</footer>
</html>

View File

@ -1,4 +1,4 @@
package input
package raw
import (
"bytes"
@ -6,19 +6,15 @@ import (
"io"
"math/rand"
"os"
"sort"
"time"
"github.com/go-yaml/yaml"
)
type Button struct {
Char byte
Down bool
}
type Random struct {
generator func() byte
down []Button
down []byte
}
func NewRandom(generator func() byte) *Random {
@ -29,19 +25,8 @@ func NewRandom(generator func() byte) *Random {
func (r *Random) Close() {
}
func (r *Random) Read() []Button {
if len(r.down) > 0 && rand.Int()%2 == 0 {
was := r.down
for i := range was {
was[i].Down = false
}
r.down = r.down[:0]
return was
} else {
c := Button{Char: r.generator(), Down: true}
r.down = append(r.down, c)
return []Button{c}
}
func (r *Random) Read() []byte {
return []byte{r.generator()}
}
func randomCharFromRange(start, stop byte) func() byte {
@ -87,11 +72,15 @@ func randomCharFromWeights(m map[byte]int) func() byte {
}
sum += v
}
sort.Slice(result, func(i, j int) bool {
return result[i].i < result[j].i
})
if sum <= 0 {
panic("weights must total nonzero")
}
return func() byte {
n := rand.Int() % sum
r := rand.Int()
n := r % (sum + 1)
for _, v := range result {
n -= v.i
if n <= 0 {

View File

@ -1,4 +1,4 @@
package input
package raw
import (
"strings"
@ -8,7 +8,7 @@ import (
func TestRandomCharFromWeights(t *testing.T) {
weights := map[byte]int{
'a': 1,
'b': 99,
'b': 20,
}
foo := randomCharFromWeights(weights)
for {

View File

@ -0,0 +1,37 @@
package raw
import (
"context"
"os"
"strconv"
)
var (
FlagRawKeyboard = os.Getenv("RAW_KEYBOARD") == "true"
FlagRawUDP = os.Getenv("RAW_UDP")
FlagRawWS = os.Getenv("RAW_WS")
FlagDebug = os.Getenv("DEBUG") != ""
FlagRawRandomWeightFile = os.Getenv("RAW_RANDOM_WEIGHT_FILE")
)
type Raw interface {
Read() []byte
Close()
}
func New(ctx context.Context) Raw {
if FlagRawKeyboard {
return NewKeyboard()
}
if port, _ := strconv.Atoi(FlagRawWS); port != 0 {
return NewWS(ctx, port)
}
if port, _ := strconv.Atoi(FlagRawUDP); port != 0 {
return NewUDP(ctx, port)
}
generator := randomCharFromRange('a', 'g')
if FlagRawRandomWeightFile != "" {
generator = randomCharFromWeightFile(FlagRawRandomWeightFile)
}
return NewRandom(generator)
}

View File

@ -0,0 +1,10 @@
package raw
import "testing"
func TestRaw(t *testing.T) {
var _ Raw = &Random{}
var _ Raw = UDP{}
var _ Raw = Keyboard{}
var _ Raw = WS{}
}

View File

@ -0,0 +1,58 @@
package raw
import (
"context"
"log"
"net"
"strconv"
)
type UDP struct {
conn net.PacketConn
c chan []byte
ctx context.Context
}
func NewUDP(ctx context.Context, port int) UDP {
conn, err := net.ListenPacket("udp", ":"+strconv.Itoa(port))
if err != nil {
panic(err)
}
result := UDP{
conn: conn,
c: make(chan []byte, 8),
ctx: ctx,
}
go result.listen()
return result
}
func (udp UDP) listen() {
for udp.ctx.Err() == nil {
buff := make([]byte, 256)
n, _, err := udp.conn.ReadFrom(buff)
if err != nil && udp.ctx.Err() == nil {
panic(err)
}
if FlagDebug {
log.Printf("raw.UDP.Read() => %s", buff[:n])
}
select {
case udp.c <- buff[:n]:
case <-udp.ctx.Done():
}
}
}
func (udp UDP) Read() []byte {
select {
case v := <-udp.c:
return v
case <-udp.ctx.Done():
return []byte{}
}
}
func (udp UDP) Close() {
udp.conn.Close()
}

148
src/device/input/raw/ws.go Normal file
View File

@ -0,0 +1,148 @@
package raw
import (
"context"
"embed"
_ "embed"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path"
"strings"
"github.com/gorilla/websocket"
)
var (
FlagWSProxy = os.Getenv("RAW_WS_PROXY_URL")
FlagWSDebug = os.Getenv("RAW_WS_DEBUG") != ""
)
type WS struct {
ctx context.Context
can context.CancelFunc
ch chan []byte
}
func NewWS(ctx context.Context, port int) WS {
ctx, can := context.WithCancel(ctx)
ws := WS{ctx: ctx, can: can, ch: make(chan []byte, 256)}
go ws.listen(port)
return ws
}
func (ws WS) Read() []byte {
select {
case v := <-ws.ch:
return v
case <-ws.ctx.Done():
return nil
}
}
func (ws WS) Close() {
ws.can()
}
func (ws WS) listen(port int) {
server := &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: ws,
}
go func() {
if err := server.ListenAndServe(); err != nil && ws.ctx.Err() == nil {
panic(err)
}
}()
log.Println("WS on", port)
<-ws.ctx.Done()
server.Close()
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
func (ws WS) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(ws.ctx)
if err := ws.serveHTTP(w, r); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (ws WS) serveHTTP(w http.ResponseWriter, r *http.Request) error {
switch r.URL.Path {
case "/api/ws":
return ws.serveWS(w, r)
}
if strings.HasPrefix(r.URL.Path, "/proxy") {
return ws.serveProxy(w, r)
}
return ws.serveStaticFile(w, r)
}
func (ws WS) serveProxy(w http.ResponseWriter, r *http.Request) error {
u, err := url.Parse(FlagWSProxy)
if err != nil {
return err
}
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/proxy")
if r.URL.Path == "" {
r.URL.Path = "/"
}
proxy := httputil.NewSingleHostReverseProxy(u)
proxy.ServeHTTP(w, r)
return nil
}
//go:embed public/*
var staticFiles embed.FS
func (ws WS) serveStaticFile(w http.ResponseWriter, r *http.Request) error {
if FlagWSDebug {
b, _ := os.ReadFile("src/device/input/raw/public/root.html")
w.Write(b)
return nil
}
if r.URL.Path == "/" {
r.URL.Path = "root.html"
}
r.URL.Path = path.Join("public", r.URL.Path)
http.FileServer(http.FS(staticFiles)).ServeHTTP(w, r)
return nil
}
func (ws WS) serveWS(w http.ResponseWriter, r *http.Request) error {
if err := ws._serveWS(w, r); err != nil {
log.Println("_serveWS:", err)
}
return nil
}
func (ws WS) _serveWS(w http.ResponseWriter, r *http.Request) error {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return err
}
defer conn.Close()
for ws.ctx.Err() == nil {
msgType, p, err := conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err) || websocket.IsUnexpectedCloseError(err) {
return nil
}
return err
}
if msgType == websocket.TextMessage {
log.Println(string(p))
ws.ch <- p
}
}
return nil
}

View File

@ -1,31 +1,42 @@
package input
package wrap
import (
"context"
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/raw"
"os"
"sync"
"time"
)
var (
FlagBufferedStickyDuration = os.Getenv("WRAP_BUFFERED_STICKY_DURATION")
)
type Buffered struct {
ctx context.Context
can context.CancelFunc
lock sync.Mutex
keys map[byte]int64
input Input
input Wrap
listenInterval time.Duration
expirationInterval time.Duration
}
func NewBuffered(ctx context.Context, input Input) *Buffered {
func NewBuffered(ctx context.Context, input Wrap) *Buffered {
ctx, can := context.WithCancel(ctx)
expirationInterval := time.Millisecond * 125
if d, err := time.ParseDuration(FlagBufferedStickyDuration); err == nil {
expirationInterval = d
}
result := &Buffered{
input: input,
ctx: ctx,
can: can,
lock: sync.Mutex{},
keys: map[byte]int64{},
listenInterval: time.Millisecond * 20,
expirationInterval: time.Millisecond * 100,
listenInterval: time.Millisecond * 10,
expirationInterval: expirationInterval,
}
go result.listen()
return result
@ -39,6 +50,8 @@ func (b *Buffered) listen() {
for i := range buttons {
if buttons[i].Down {
b.keys[buttons[i].Char] = time.Now().UnixNano()
} else {
b.keys[buttons[i].Char] = 0
}
}
b.lock.Unlock()
@ -49,12 +62,17 @@ func (b *Buffered) listen() {
}
}
func (b *Buffered) CloseWrap() raw.Raw {
b.can()
return b.input.CloseWrap()
}
func (b *Buffered) Close() {
b.input.Close()
b.can()
}
func (b *Buffered) Read() []Button {
func (b *Buffered) Read() []button.Button {
for b.ctx.Err() == nil {
result := b.read()
if len(result) > 0 {
@ -65,19 +83,19 @@ func (b *Buffered) Read() []Button {
case <-time.After(b.listenInterval):
}
}
return []Button{}
return []button.Button{}
}
func (b *Buffered) read() []Button {
func (b *Buffered) read() []button.Button {
b.lock.Lock()
defer b.lock.Unlock()
result := make([]Button, 0, len(b.keys))
result := make([]button.Button, 0, len(b.keys))
for k, v := range b.keys {
isFresh := v > 0
isStale := v < 0 && time.Since(time.Unix(0, -1*v)) > b.expirationInterval
if isFresh || isStale {
result = append(result, Button{Char: k, Down: isFresh})
result = append(result, button.Button{Char: k, Down: isFresh})
}
if isFresh {
b.keys[k] = -1 * v

View File

@ -0,0 +1,64 @@
package wrap
import (
"context"
"log"
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/raw"
"os"
"os/signal"
"syscall"
)
var (
ChSigUsr1 = func() chan os.Signal {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGUSR1)
return c
}()
)
type Refresh struct {
can context.CancelFunc
input Wrap
}
func NewRefresh(ctx context.Context, newWrap func() Wrap) *Refresh {
return NewRefreshWith(ctx, newWrap, ChSigUsr1)
}
func NewRefreshWith(ctx context.Context, newWrap func() Wrap, ch <-chan os.Signal) *Refresh {
ctx, can := context.WithCancel(ctx)
result := &Refresh{
can: can,
input: newWrap(),
}
go func() {
defer log.Println("refreshing done")
for {
select {
case <-ctx.Done():
return
case sig := <-ch:
log.Println("refreshing for", sig)
result.input.CloseWrap()
result.input = newWrap()
}
}
}()
return result
}
func (r *Refresh) CloseWrap() raw.Raw {
r.can()
return r.input.CloseWrap()
}
func (r *Refresh) Read() []button.Button {
return r.input.Read()
}
func (r *Refresh) Close() {
r.can()
r.input.Close()
}

View File

@ -0,0 +1,101 @@
package wrap
import (
"context"
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/raw"
"os"
"syscall"
"testing"
"time"
)
func TestRefresh(t *testing.T) {
b := byte('a')
generator := func() Wrap {
b += byte(1)
return dummyParser{Char: b}
}
ch := make(chan os.Signal, 1)
defer close(ch)
refresh := NewRefreshWith(context.Background(), generator, ch)
defer refresh.Close()
assertIts := func(t *testing.T, b byte) {
for i := 0; i < 10; i++ {
some := false
for j, button := range refresh.Read() {
some = true
if button.Char != b {
t.Error(i, j, b, button)
}
}
if !some {
t.Error("empty read")
}
}
}
t.Run("called once on init", func(t *testing.T) {
assertIts(t, byte('b'))
})
ch <- syscall.SIGUSR1
time.Sleep(time.Millisecond * 450)
t.Run("called once on signal", func(t *testing.T) {
assertIts(t, byte('c'))
})
}
func TestRefreshDoesntCloseSources(t *testing.T) {
src := &telemetrySrc{}
newParsers := 0
newParser := func() Wrap {
newParsers += 1
return src
}
ctx, can := context.WithCancel(context.Background())
defer can()
refresh := NewRefresh(ctx, newParser)
if newParsers != 1 {
t.Error(newParsers)
}
for i := 0; i < 5; i++ {
refresh.Read()
}
if want := (telemetrySrc{reads: 5}); *src != want {
t.Errorf("%+v", *src)
} else if newParsers != 1 {
t.Error(newParsers)
}
for i := 0; i < 5; i++ {
ChSigUsr1 <- syscall.SIGINT
}
time.Sleep(time.Millisecond * 250)
if want := (telemetrySrc{reads: 5, closeWraps: 5}); *src != want {
t.Errorf("want %+v, got %+v", want, *src)
} else if newParsers != 6 {
t.Error(newParsers)
}
}
type telemetrySrc struct {
closeWraps int
closes int
reads int
}
func (src *telemetrySrc) CloseWrap() raw.Raw {
src.closeWraps += 1
return nil
}
func (src *telemetrySrc) Close() {
src.closes += 1
}
func (src *telemetrySrc) Read() []button.Button {
src.reads += 1
return nil
}

View File

@ -1,17 +1,19 @@
package input
package wrap
import (
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/raw"
"os"
"github.com/go-yaml/yaml"
)
type Remap struct {
input Input
input Wrap
m map[byte]byte
}
func NewRemapFromFile(input Input, p string) Remap {
func NewRemapFromFile(input Wrap, p string) Remap {
b, err := os.ReadFile(p)
if err != nil {
panic(err)
@ -30,18 +32,22 @@ func NewRemapFromFile(input Input, p string) Remap {
return NewRemap(input, remap)
}
func NewRemap(input Input, m map[byte]byte) Remap {
func NewRemap(input Wrap, m map[byte]byte) Remap {
return Remap{
input: input,
m: m,
}
}
func (re Remap) CloseWrap() raw.Raw {
return re.input.CloseWrap()
}
func (re Remap) Close() {
re.input.Close()
}
func (re Remap) Read() []Button {
func (re Remap) Read() []button.Button {
result := re.input.Read()
for i := range result {
if v, ok := re.m[result[i].Char]; ok {

View File

@ -0,0 +1,45 @@
package wrap
import (
"context"
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/raw"
"os"
)
var (
FlagBuffered = os.Getenv("WRAP_BUFFERED") == "true"
FlagRemapFile = os.Getenv("WRAP_REMAP_FILE")
FlagRefreshOnSigUsr1 = os.Getenv("WRAP_REFRESH_ON_SIGUSR1") == "true"
)
type Wrap interface {
Read() []button.Button
Close()
CloseWrap() raw.Raw
}
func New(ctx context.Context, parserFunc func() Wrap) Wrap {
maker := func() Wrap {
return parserFunc()
}
if FlagBuffered {
oldMaker := maker
maker = func() Wrap {
return NewBuffered(ctx, oldMaker())
}
}
if FlagRemapFile != "" {
oldMaker := maker
maker = func() Wrap {
return NewRemapFromFile(oldMaker(), FlagRemapFile)
}
}
if FlagRefreshOnSigUsr1 {
oldMaker := maker
maker = func() Wrap {
return NewRefresh(ctx, oldMaker)
}
}
return maker()
}

View File

@ -0,0 +1,22 @@
package wrap
import (
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/raw"
"testing"
)
func TestWrap(t *testing.T) {
var _ Wrap = dummyParser{}
var _ Wrap = &Refresh{}
var _ Wrap = &Buffered{}
var _ Wrap = &Remap{}
}
type dummyParser button.Button
func (d dummyParser) CloseWrap() raw.Raw { return nil }
func (d dummyParser) Close() {}
func (d dummyParser) Read() []button.Button {
return []button.Button{button.Button(d)}
}

View File

@ -28,6 +28,33 @@ var (
'x': X,
'y': Y,
'z': Z,
'1': N1,
'2': N2,
'3': N3,
'4': N4,
'5': N5,
'6': N6,
'7': N7,
'8': N8,
'9': N9,
'0': N0,
'!': F1,
'@': F2,
'#': F3,
'$': F4,
'%': F5,
'^': F6,
'&': F7,
'*': F8,
'(': F9,
')': F10,
',': PComma,
'/': PFSlash,
';': PSemicolon,
'-': PMinus,
'=': PEqual,
'<': PageDown,
'>': PageUp,
}
keyToChar = func() map[Key]byte {
result := map[Key]byte{}
@ -47,7 +74,7 @@ func ToChar(k Key) byte {
}
func FromChar(b byte) Key {
if b < 'a' {
if 'A' <= b && b <= 'Z' {
b += 'a' - 'A'
}
v, ok := charToKey[b]

View File

@ -3,13 +3,16 @@ package key
import "testing"
func TestFromChar(t *testing.T) {
if got := FromChar('1'); got != N1 {
t.Error(got)
}
if got := FromChar('a'); got != A {
t.Error(got)
}
if got := FromChar('A'); got != A {
t.Error(got)
}
if got := FromChar('!'); got != Undef {
if got := FromChar(byte(0)); got != Undef {
t.Error(got)
}
if got := ToChar(A); got != 'a' {

View File

@ -5,31 +5,58 @@ import "github.com/micmonay/keybd_event"
type Key int
const (
Undef = Key(keybd_event.VK_SP11)
A = Key(keybd_event.VK_A)
B = Key(keybd_event.VK_B)
C = Key(keybd_event.VK_C)
D = Key(keybd_event.VK_D)
E = Key(keybd_event.VK_E)
F = Key(keybd_event.VK_F)
G = Key(keybd_event.VK_G)
H = Key(keybd_event.VK_H)
I = Key(keybd_event.VK_I)
J = Key(keybd_event.VK_J)
K = Key(keybd_event.VK_K)
L = Key(keybd_event.VK_L)
M = Key(keybd_event.VK_M)
N = Key(keybd_event.VK_N)
O = Key(keybd_event.VK_O)
P = Key(keybd_event.VK_P)
Q = Key(keybd_event.VK_Q)
R = Key(keybd_event.VK_R)
S = Key(keybd_event.VK_S)
T = Key(keybd_event.VK_T)
U = Key(keybd_event.VK_U)
V = Key(keybd_event.VK_V)
W = Key(keybd_event.VK_W)
X = Key(keybd_event.VK_X)
Y = Key(keybd_event.VK_Y)
Z = Key(keybd_event.VK_Z)
Undef = Key(keybd_event.VK_SP11)
A = Key(keybd_event.VK_A)
B = Key(keybd_event.VK_B)
C = Key(keybd_event.VK_C)
D = Key(keybd_event.VK_D)
E = Key(keybd_event.VK_E)
F = Key(keybd_event.VK_F)
G = Key(keybd_event.VK_G)
H = Key(keybd_event.VK_H)
I = Key(keybd_event.VK_I)
J = Key(keybd_event.VK_J)
K = Key(keybd_event.VK_K)
L = Key(keybd_event.VK_L)
M = Key(keybd_event.VK_M)
N = Key(keybd_event.VK_N)
O = Key(keybd_event.VK_O)
P = Key(keybd_event.VK_P)
Q = Key(keybd_event.VK_Q)
R = Key(keybd_event.VK_R)
S = Key(keybd_event.VK_S)
T = Key(keybd_event.VK_T)
U = Key(keybd_event.VK_U)
V = Key(keybd_event.VK_V)
W = Key(keybd_event.VK_W)
X = Key(keybd_event.VK_X)
Y = Key(keybd_event.VK_Y)
Z = Key(keybd_event.VK_Z)
N1 = Key(keybd_event.VK_1)
N2 = Key(keybd_event.VK_2)
N3 = Key(keybd_event.VK_3)
N4 = Key(keybd_event.VK_4)
N5 = Key(keybd_event.VK_5)
N6 = Key(keybd_event.VK_6)
N7 = Key(keybd_event.VK_7)
N8 = Key(keybd_event.VK_8)
N9 = Key(keybd_event.VK_9)
N0 = Key(keybd_event.VK_0)
F1 = Key(keybd_event.VK_F1)
F2 = Key(keybd_event.VK_F2)
F3 = Key(keybd_event.VK_F3)
F4 = Key(keybd_event.VK_F4)
F5 = Key(keybd_event.VK_F5)
F6 = Key(keybd_event.VK_F6)
F7 = Key(keybd_event.VK_F7)
F8 = Key(keybd_event.VK_F8)
F9 = Key(keybd_event.VK_F9)
F10 = Key(keybd_event.VK_F10)
PComma = Key(keybd_event.VK_COMMA)
PFSlash = Key(keybd_event.VK_BACKSLASH)
PSemicolon = Key(keybd_event.VK_SEMICOLON)
PMinus = Key(keybd_event.VK_MINUS)
PEqual = Key(keybd_event.VK_EQUAL)
PageUp = Key(keybd_event.VK_PAGEUP)
PageDown = Key(keybd_event.VK_PAGEDOWN)
)

View File

@ -2,6 +2,7 @@ package src
import (
"context"
"log"
"mayhem-party/src/device/input"
"mayhem-party/src/device/output"
"mayhem-party/src/device/output/key"
@ -16,9 +17,9 @@ func Main(ctx context.Context) error {
defer reader.Close()
interval := time.Millisecond * 50
if intervalS, ok := os.LookupEnv("MAIN_INTERVAL_DURATION"); !ok {
if intervalS := os.Getenv("MAIN_INTERVAL_DURATION"); intervalS == "" {
} else if v, err := time.ParseDuration(intervalS); err != nil {
panic(err)
return err
} else {
interval = v
}
@ -37,6 +38,9 @@ func Main(ctx context.Context) error {
state := map[key.Key]bool{}
for block() {
delta := reader.Read()
if os.Getenv("DEBUG") == "true" {
log.Printf("src.Main.reader.Read(): %+v", delta)
}
for _, button := range delta {
state[key.FromChar(button.Char)] = button.Down
}
@ -46,6 +50,9 @@ func Main(ctx context.Context) error {
keys = append(keys, k)
}
}
if os.Getenv("DEBUG") == "true" {
log.Printf("src.Main.writer.Press(%+v) (from %+v)", keys, delta)
}
writer.Press(keys...)
}

View File

@ -2,3 +2,9 @@ w: i
a: j
s: k
d: l
q: u
e: o
1: 0
2: 9
3: 8
4: 7

View File

@ -1,2 +1,2 @@
export INPUT_BUFFERED=true
export INPUT_KEYBOARD=true
export WRAP_BUFFERED=true
export RAW_KEYBOARD=true

View File

@ -1,2 +1,2 @@
export INPUT_RANDOM_WEIGHT_FILE=testdata/INPUT_RANDOM_WEIGHT_FILE.yaml
export INPUT_REMAP_FILE=testdata/INPUT_REMAP_FILE.yaml
export RAW_RANDOM_WEIGHT_FILE=testdata/INPUT_RANDOM_WEIGHT_FILE.yaml
export WRAP_REMAP_FILE=testdata/INPUT_REMAP_FILE.yaml

View File

@ -1,3 +1,3 @@
export OUTPUT_KEYBOARD=true
export MAIN_INTERVAL_DURATION=250ms
export INPUT_RANDOM_WEIGHT_FILE=testdata/INPUT_RANDOM_WEIGHT_FILE.yaml
export RAW_RANDOM_WEIGHT_FILE=testdata/INPUT_RANDOM_WEIGHT_FILE.yaml