init
This commit is contained in:
commit
7cc71fb719
13 changed files with 840 additions and 0 deletions
140
README.md
Normal file
140
README.md
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
# System prototype
|
||||||
|
The prototype must be able to work with a configuration file and a set of external events of a certain format.
|
||||||
|
Solution should contain golang (1.22 or newer) source file/files and unit tests (optional)
|
||||||
|
|
||||||
|
|
||||||
|
## Description
|
||||||
|
A player is participating in a challenge. The goal is to completely clear a dungeon. The player navigates through floors and fights monsters. We need to process the events and compile the information into a final report
|
||||||
|
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
1. Only registered players are allowed to participate in the challenge
|
||||||
|
2. The challenge ends if:
|
||||||
|
1. The player leaves the dungeon
|
||||||
|
2. The player cannot continue the challenge
|
||||||
|
3. The dungeon opening time has expired
|
||||||
|
4. Player is dead (health drops to 0)
|
||||||
|
3. When entering the boss's floor, the player receives a notification
|
||||||
|
4. The boss floor does not contain any monsters
|
||||||
|
5. The dungeon is considered complete if:
|
||||||
|
1. All floors are cleared of monsters
|
||||||
|
2. The boss is defeated
|
||||||
|
6. A floor is considered complete when all monsters or the boss have been killed; ***any time spent in that floor is no longer counted***
|
||||||
|
7. The player's health cannot exceed 100
|
||||||
|
|
||||||
|
|
||||||
|
## Events
|
||||||
|
- All events occur sequentially in time. (***Time of event N+1***) >= (***Time of event N***)
|
||||||
|
- Time format ***[HH:MM:SS]***. Trailing zeros are required in input and output
|
||||||
|
- The ***ExtraParam*** parameter can be a string containing multiple words.
|
||||||
|
|
||||||
|
#### Incoming events
|
||||||
|
| EventID | ExtraParam | Comment |
|
||||||
|
| ----------|:-------------:|-----------------------------------------------------|
|
||||||
|
| 1 | | Player [`id`] registered |
|
||||||
|
| 2 | | Player [`id`] entered the dungeon |
|
||||||
|
| 3 | | Player [`id`] killed the monster |
|
||||||
|
| 4 | | Player [`id`] went to the next floor |
|
||||||
|
| 5 | | Player [`id`] went to the previous floor |
|
||||||
|
| 6 | | Player [`id`] entered the boss's floor |
|
||||||
|
| 7 | | Player [`id`] killed the boss |
|
||||||
|
| 8 | | Player [`id`] left the dungeon |
|
||||||
|
| 9 | `reason` | Player [`id`] cannot continue due to [`reason`] |
|
||||||
|
| 10 | `health` | Player [`id`] has restored [`health`] of health |
|
||||||
|
| 11 | `damage` | Player [`id`] recieved [`damage`] of damage |
|
||||||
|
|
||||||
|
#### Outgoing events
|
||||||
|
| EventID | ExtraParam | Comment |
|
||||||
|
| ----------|:-------------:|---------------------------------------------------|
|
||||||
|
| 31 | | Player [`id`] disqualified |
|
||||||
|
| 32 | | Player [`id`] is dead |
|
||||||
|
| 33 | | Player [`id`] makes imposible move [`eventID`] |
|
||||||
|
|
||||||
|
#### Example
|
||||||
|
|
||||||
|
```
|
||||||
|
[14:00:00] 1 1
|
||||||
|
[14:00:00] 2 1
|
||||||
|
[14:10:00] 2 2
|
||||||
|
[14:10:00] 3 2
|
||||||
|
[14:11:00] 2 5
|
||||||
|
[14:12:00] 3 3
|
||||||
|
[14:14:00] 2 3
|
||||||
|
[14:27:00] 2 11 60
|
||||||
|
[14:29:00] 2 11 50
|
||||||
|
[14:40:00] 1 2
|
||||||
|
[14:41:00] 1 3
|
||||||
|
[14:44:00] 1 11 50
|
||||||
|
[14:45:00] 1 3
|
||||||
|
[14:48:00] 1 4
|
||||||
|
[14:48:00] 1 6
|
||||||
|
[14:49:00] 1 11 25
|
||||||
|
[14:49:02] 1 10 80
|
||||||
|
[14:50:00] 1 11 65
|
||||||
|
[14:59:00] 1 7
|
||||||
|
[15:04:00] 1 8
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
```
|
||||||
|
[14:00:00] Player [1] registered
|
||||||
|
[14:00:00] Player [2] registered
|
||||||
|
[14:10:00] Player [2] entered the dungeon
|
||||||
|
[14:10:00] Player [3] is disqualified
|
||||||
|
[14:11:00] Player [2] makes imposible move [5]
|
||||||
|
[14:14:00] Player [2] killed the monster
|
||||||
|
[14:27:00] Player [2] recieved [60] of damage
|
||||||
|
[14:29:00] Player [2] recieved [50] of damage
|
||||||
|
[14:29:00] Player [2] is dead
|
||||||
|
[14:40:00] Player [1] entered the dungeon
|
||||||
|
[14:41:00] Player [1] killed the monster
|
||||||
|
[14:44:00] Player [1] recieved [50] of damage
|
||||||
|
[14:45:00] Player [1] killed the monster
|
||||||
|
[14:48:00] Player [1] went to the next floor
|
||||||
|
[14:48:00] Player [1] entered the boss's floor
|
||||||
|
[14:49:00] Player [1] recieved [25] of damage
|
||||||
|
[14:49:02] Player [1] has restored [80] of health
|
||||||
|
[14:50:00] Player [1] recieved [65] of damage
|
||||||
|
[14:59:00] Player [1] killed the boss
|
||||||
|
[15:04:00] Player [1] left the dungeon
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration (json)
|
||||||
|
- **Floors** - Number of floors in the dungeon
|
||||||
|
- **Monsters** - Number of monsters on each floor of the dungeon
|
||||||
|
- **OpenAt** - Dungeon opening time
|
||||||
|
- **Duration** - Time until the dungeon closes in hours
|
||||||
|
|
||||||
|
|
||||||
|
#### Example
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"Floors": 2,
|
||||||
|
"Monsters": 2,
|
||||||
|
"OpenAt": "14:05:00",
|
||||||
|
"Duration": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## States
|
||||||
|
| State | Comment |
|
||||||
|
| ----------|:-----------------------------------------------------------------:|
|
||||||
|
| SUCCESS | All floors are cleared |
|
||||||
|
| FAIL | The player died or the dungeon is not considered completed |
|
||||||
|
| DISQUAL | The player cannot continue or has not completed registration |
|
||||||
|
|
||||||
|
## Final report
|
||||||
|
1. State `SUCCESS`/`FAIL`/`DISQUAL`
|
||||||
|
2. Player ID
|
||||||
|
3. Time spent in the dungeon (all the time until the player left the dungeon or the dungeon closed)
|
||||||
|
4. Average time to clear a floor of monsters (the boss's floor is not included in the calculation)
|
||||||
|
5. Time to kill the boss
|
||||||
|
6. Player health at the end of the trial
|
||||||
|
|
||||||
|
#### Example
|
||||||
|
```
|
||||||
|
Final report:
|
||||||
|
[SUCCESS] 1 [00:24:00, 00:05:00, 00:11:00] HP:35
|
||||||
|
[FAIL] 2 [00:19:00, 00:00:00, 00:00:00] HP:0
|
||||||
|
[DISQUAL] 3 [00:00:00, 00:00:00, 00:00:00] HP:100
|
||||||
|
```
|
||||||
6
config.json
Normal file
6
config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"Floors": 2,
|
||||||
|
"Monsters": 2,
|
||||||
|
"OpenAt": "14:05:00",
|
||||||
|
"Duration": 2
|
||||||
|
}
|
||||||
20
events
Normal file
20
events
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[14:00:00] 1 1
|
||||||
|
[14:00:00] 2 1
|
||||||
|
[14:10:00] 2 2
|
||||||
|
[14:10:00] 3 2
|
||||||
|
[14:11:00] 2 5
|
||||||
|
[14:12:00] 3 3
|
||||||
|
[14:14:00] 2 3
|
||||||
|
[14:27:00] 2 11 60
|
||||||
|
[14:29:00] 2 11 50
|
||||||
|
[14:40:00] 1 2
|
||||||
|
[14:41:00] 1 3
|
||||||
|
[14:44:00] 1 11 50
|
||||||
|
[14:45:00] 1 3
|
||||||
|
[14:48:00] 1 4
|
||||||
|
[14:48:00] 1 6
|
||||||
|
[14:49:00] 1 11 25
|
||||||
|
[14:49:02] 1 10 80
|
||||||
|
[14:50:00] 1 11 65
|
||||||
|
[14:59:00] 1 7
|
||||||
|
[15:04:00] 1 8
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module impulse
|
||||||
|
|
||||||
|
go 1.22
|
||||||
130
internal/challenge/actions.go
Normal file
130
internal/challenge/actions.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
package challenge
|
||||||
|
|
||||||
|
func (e *Engine) register(event Event, p *Player) {
|
||||||
|
if p.Registered || p.InDungeon {
|
||||||
|
e.impossible(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Registered = true
|
||||||
|
e.log(event.At, "Player [%d] registered", p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) enterDungeon(event Event, p *Player) {
|
||||||
|
if p.InDungeon || event.At < e.openAt || event.At >= e.closeAt {
|
||||||
|
e.impossible(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.InDungeon = true
|
||||||
|
p.Attempted = true
|
||||||
|
p.EnteredAt = event.At
|
||||||
|
p.EndedAt = event.At
|
||||||
|
p.Floor = 1
|
||||||
|
e.startFloorTimer(p, event.At)
|
||||||
|
e.log(event.At, "Player [%d] entered the dungeon", p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) killMonster(event Event, p *Player) {
|
||||||
|
if !p.InDungeon || p.Floor < 1 || p.Floor > e.regularFloors || p.MonsterKills[p.Floor] >= e.cfg.Monsters {
|
||||||
|
e.impossible(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.MonsterKills[p.Floor]++
|
||||||
|
e.log(event.At, "Player [%d] killed the monster", p.ID)
|
||||||
|
if p.MonsterKills[p.Floor] == e.cfg.Monsters {
|
||||||
|
e.finishFloorTimer(p, event.At)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) nextFloor(event Event, p *Player) {
|
||||||
|
if !p.InDungeon || p.Floor < 1 || p.Floor >= e.cfg.Floors || !e.floorCleared(p, p.Floor) {
|
||||||
|
e.impossible(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.pauseFloorTimer(p, event.At)
|
||||||
|
p.Floor++
|
||||||
|
e.startFloorTimer(p, event.At)
|
||||||
|
e.log(event.At, "Player [%d] went to the next floor", p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) previousFloor(event Event, p *Player) {
|
||||||
|
if !p.InDungeon || p.Floor <= 1 {
|
||||||
|
e.impossible(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.pauseFloorTimer(p, event.At)
|
||||||
|
p.Floor--
|
||||||
|
e.startFloorTimer(p, event.At)
|
||||||
|
e.log(event.At, "Player [%d] went to the previous floor", p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) enterBoss(event Event, p *Player) {
|
||||||
|
if !p.InDungeon || p.BossEntered || p.Floor != e.cfg.Floors || !e.allRegularFloorsCleared(p) {
|
||||||
|
e.impossible(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.BossEntered = true
|
||||||
|
p.BossEnterAt = event.At
|
||||||
|
e.log(event.At, "Player [%d] entered the boss's floor", p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) killBoss(event Event, p *Player) {
|
||||||
|
if !p.InDungeon || !p.BossEntered || p.BossKilled {
|
||||||
|
e.impossible(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.BossKilled = true
|
||||||
|
p.BossTime = event.At - p.BossEnterAt
|
||||||
|
e.log(event.At, "Player [%d] killed the boss", p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) leaveDungeon(event Event, p *Player) {
|
||||||
|
if !p.InDungeon {
|
||||||
|
e.impossible(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.pauseFloorTimer(p, event.At)
|
||||||
|
p.InDungeon = false
|
||||||
|
p.Left = true
|
||||||
|
p.Terminal = true
|
||||||
|
p.EndedAt = event.At
|
||||||
|
e.log(event.At, "Player [%d] left the dungeon", p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) cannotContinue(event Event, p *Player) {
|
||||||
|
p.Disqual = true
|
||||||
|
p.Terminal = true
|
||||||
|
p.EndedAt = event.At
|
||||||
|
e.log(event.At, "Player [%d] cannot continue due to [%s]", p.ID, event.Extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) restoreHealth(event Event, p *Player) {
|
||||||
|
amount, ok := parseNonNegativeInt(event.Extra)
|
||||||
|
if !ok || !p.InDungeon {
|
||||||
|
e.impossible(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Health = min(100, p.Health+amount)
|
||||||
|
e.log(event.At, "Player [%d] has restored [%d] of health", p.ID, amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) takeDamage(event Event, p *Player) {
|
||||||
|
damage, ok := parseNonNegativeInt(event.Extra)
|
||||||
|
if !ok || !p.InDungeon {
|
||||||
|
e.impossible(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Health = max(0, p.Health-damage)
|
||||||
|
e.log(event.At, "Player [%d] recieved [%d] of damage", p.ID, damage)
|
||||||
|
if p.Health == 0 {
|
||||||
|
e.pauseFloorTimer(p, event.At)
|
||||||
|
p.Dead = true
|
||||||
|
p.InDungeon = false
|
||||||
|
p.Terminal = true
|
||||||
|
p.EndedAt = event.At
|
||||||
|
e.log(event.At, "Player [%d] is dead", p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) impossible(event Event) {
|
||||||
|
e.log(event.At, "Player [%d] makes imposible move [%d]", event.Player, event.ID)
|
||||||
|
}
|
||||||
190
internal/challenge/engine.go
Normal file
190
internal/challenge/engine.go
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
package challenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
cfg Config
|
||||||
|
openAt time.Duration
|
||||||
|
closeAt time.Duration
|
||||||
|
regularFloors int
|
||||||
|
players map[int]*Player
|
||||||
|
order map[int]bool
|
||||||
|
logs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEngine(cfg Config) (*Engine, error) {
|
||||||
|
openAt, err := parseClock(cfg.OpenAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cfg.Floors < 1 {
|
||||||
|
return nil, errors.New("Floors must be positive")
|
||||||
|
}
|
||||||
|
if cfg.Monsters < 0 {
|
||||||
|
return nil, errors.New("Monsters must not be negative")
|
||||||
|
}
|
||||||
|
if cfg.Duration <= 0 {
|
||||||
|
return nil, errors.New("Duration must be positive")
|
||||||
|
}
|
||||||
|
return &Engine{
|
||||||
|
cfg: cfg,
|
||||||
|
openAt: openAt,
|
||||||
|
closeAt: openAt + time.Duration(cfg.Duration)*time.Hour,
|
||||||
|
regularFloors: max(0, cfg.Floors-1),
|
||||||
|
players: map[int]*Player{},
|
||||||
|
order: map[int]bool{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Apply(event Event) {
|
||||||
|
e.closeExpired(event.At)
|
||||||
|
|
||||||
|
p := e.player(event.Player)
|
||||||
|
if p.Terminal {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !p.Registered && event.ID != 1 {
|
||||||
|
p.Disqual = true
|
||||||
|
p.Terminal = true
|
||||||
|
p.EndedAt = event.At
|
||||||
|
e.log(event.At, "Player [%d] is disqualified", p.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event.ID {
|
||||||
|
case 1:
|
||||||
|
e.register(event, p)
|
||||||
|
case 2:
|
||||||
|
e.enterDungeon(event, p)
|
||||||
|
case 3:
|
||||||
|
e.killMonster(event, p)
|
||||||
|
case 4:
|
||||||
|
e.nextFloor(event, p)
|
||||||
|
case 5:
|
||||||
|
e.previousFloor(event, p)
|
||||||
|
case 6:
|
||||||
|
e.enterBoss(event, p)
|
||||||
|
case 7:
|
||||||
|
e.killBoss(event, p)
|
||||||
|
case 8:
|
||||||
|
e.leaveDungeon(event, p)
|
||||||
|
case 9:
|
||||||
|
e.cannotContinue(event, p)
|
||||||
|
case 10:
|
||||||
|
e.restoreHealth(event, p)
|
||||||
|
case 11:
|
||||||
|
e.takeDamage(event, p)
|
||||||
|
default:
|
||||||
|
e.impossible(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Output() string {
|
||||||
|
e.closeExpired(e.closeAt)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, line := range e.logs {
|
||||||
|
b.WriteString(line)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.WriteString("Final report:\n")
|
||||||
|
|
||||||
|
ids := make([]int, 0, len(e.order))
|
||||||
|
for id := range e.order {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
sort.Ints(ids)
|
||||||
|
for _, id := range ids {
|
||||||
|
p := e.players[id]
|
||||||
|
state := e.state(p)
|
||||||
|
spent := time.Duration(0)
|
||||||
|
if p.Attempted {
|
||||||
|
spent = p.EndedAt - p.EnteredAt
|
||||||
|
if spent < 0 {
|
||||||
|
spent = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("[%s] %d [%s, %s, %s] HP:%d\n", state, p.ID, formatDuration(spent), formatDuration(e.avgFloorTime(p)), formatDuration(p.BossTime), p.Health))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) player(id int) *Player {
|
||||||
|
if p, ok := e.players[id]; ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
p := &Player{
|
||||||
|
ID: id,
|
||||||
|
Health: 100,
|
||||||
|
MonsterKills: make([]int, e.cfg.Floors+1),
|
||||||
|
FloorDurations: make([]time.Duration, e.cfg.Floors+1),
|
||||||
|
}
|
||||||
|
e.players[id] = p
|
||||||
|
e.order[id] = true
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) closeExpired(now time.Duration) {
|
||||||
|
if now < e.closeAt {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, p := range e.players {
|
||||||
|
if p.InDungeon && !p.Terminal {
|
||||||
|
e.pauseFloorTimer(p, e.closeAt)
|
||||||
|
p.InDungeon = false
|
||||||
|
p.Terminal = true
|
||||||
|
p.EndedAt = e.closeAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) floorCleared(p *Player, floor int) bool {
|
||||||
|
if floor > e.regularFloors {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return p.MonsterKills[floor] >= e.cfg.Monsters
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) allRegularFloorsCleared(p *Player) bool {
|
||||||
|
for floor := 1; floor <= e.regularFloors; floor++ {
|
||||||
|
if !e.floorCleared(p, floor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) avgFloorTime(p *Player) time.Duration {
|
||||||
|
var total time.Duration
|
||||||
|
cleared := 0
|
||||||
|
for floor := 1; floor <= e.regularFloors; floor++ {
|
||||||
|
if e.floorCleared(p, floor) {
|
||||||
|
total += p.FloorDurations[floor]
|
||||||
|
cleared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cleared == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return total / time.Duration(cleared)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) state(p *Player) ResultState {
|
||||||
|
if p.Disqual {
|
||||||
|
return StateDisqual
|
||||||
|
}
|
||||||
|
if e.allRegularFloorsCleared(p) && p.BossKilled {
|
||||||
|
return StateSuccess
|
||||||
|
}
|
||||||
|
return StateFail
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) log(at time.Duration, format string, args ...any) {
|
||||||
|
e.logs = append(e.logs, fmt.Sprintf("[%s] %s", formatClock(at), fmt.Sprintf(format, args...)))
|
||||||
|
}
|
||||||
70
internal/challenge/input.go
Normal file
70
internal/challenge/input.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package challenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readConfig(r io.Reader) (Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
if err := json.NewDecoder(r).Decode(&cfg); err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readEvents(r io.Reader) ([]Event, error) {
|
||||||
|
var events []Event
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
lineNo := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineNo++
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
event, err := parseEvent(line)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("line %d: %w", lineNo, err)
|
||||||
|
}
|
||||||
|
events = append(events, event)
|
||||||
|
}
|
||||||
|
return events, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEvent(line string) (Event, error) {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
return Event{}, errors.New("not enough fields")
|
||||||
|
}
|
||||||
|
if len(fields[0]) != len("[15:04:05]") || fields[0][0] != '[' || fields[0][len(fields[0])-1] != ']' {
|
||||||
|
return Event{}, errors.New("bad time format")
|
||||||
|
}
|
||||||
|
at, err := parseClock(fields[0][1 : len(fields[0])-1])
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, err
|
||||||
|
}
|
||||||
|
player, err := strconv.Atoi(fields[1])
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, err
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(fields[2])
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Event{At: at, Player: player, ID: id, Extra: strings.Join(fields[3:], " ")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNonNegativeInt(value string) (int, bool) {
|
||||||
|
if strings.TrimSpace(value) != value || value == "" || strings.Contains(value, " ") {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(value)
|
||||||
|
return n, err == nil && n >= 0
|
||||||
|
}
|
||||||
92
internal/challenge/main_test.go
Normal file
92
internal/challenge/main_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package challenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSample(t *testing.T) {
|
||||||
|
out, err := Run("../../config.json", "../../events")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := strings.TrimLeft(`
|
||||||
|
[14:00:00] Player [1] registered
|
||||||
|
[14:00:00] Player [2] registered
|
||||||
|
[14:10:00] Player [2] entered the dungeon
|
||||||
|
[14:10:00] Player [3] is disqualified
|
||||||
|
[14:11:00] Player [2] makes imposible move [5]
|
||||||
|
[14:14:00] Player [2] killed the monster
|
||||||
|
[14:27:00] Player [2] recieved [60] of damage
|
||||||
|
[14:29:00] Player [2] recieved [50] of damage
|
||||||
|
[14:29:00] Player [2] is dead
|
||||||
|
[14:40:00] Player [1] entered the dungeon
|
||||||
|
[14:41:00] Player [1] killed the monster
|
||||||
|
[14:44:00] Player [1] recieved [50] of damage
|
||||||
|
[14:45:00] Player [1] killed the monster
|
||||||
|
[14:48:00] Player [1] went to the next floor
|
||||||
|
[14:48:00] Player [1] entered the boss's floor
|
||||||
|
[14:49:00] Player [1] recieved [25] of damage
|
||||||
|
[14:49:02] Player [1] has restored [80] of health
|
||||||
|
[14:50:00] Player [1] recieved [65] of damage
|
||||||
|
[14:59:00] Player [1] killed the boss
|
||||||
|
[15:04:00] Player [1] left the dungeon
|
||||||
|
Final report:
|
||||||
|
[SUCCESS] 1 [00:24:00, 00:05:00, 00:11:00] HP:35
|
||||||
|
[FAIL] 2 [00:19:00, 00:00:00, 00:00:00] HP:0
|
||||||
|
[DISQUAL] 3 [00:00:00, 00:00:00, 00:00:00] HP:100
|
||||||
|
`, "\n")
|
||||||
|
|
||||||
|
if out != want {
|
||||||
|
t.Fatalf("unexpected output\nwant:\n%s\ngot:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthIsCappedAndTerminalEventsIgnored(t *testing.T) {
|
||||||
|
e, err := NewEngine(Config{Floors: 1, OpenAt: "10:00:00", Duration: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.Apply(Event{At: mustClock("10:00:00"), Player: 7, ID: 1})
|
||||||
|
e.Apply(Event{At: mustClock("10:00:01"), Player: 7, ID: 2})
|
||||||
|
e.Apply(Event{At: mustClock("10:00:02"), Player: 7, ID: 11, Extra: "70"})
|
||||||
|
e.Apply(Event{At: mustClock("10:00:03"), Player: 7, ID: 10, Extra: "90"})
|
||||||
|
e.Apply(Event{At: mustClock("10:00:04"), Player: 7, ID: 11, Extra: "100"})
|
||||||
|
e.Apply(Event{At: mustClock("10:00:05"), Player: 7, ID: 10, Extra: "10"})
|
||||||
|
|
||||||
|
p := e.players[7]
|
||||||
|
if p.Health != 0 || !p.Dead || !p.Terminal {
|
||||||
|
t.Fatalf("unexpected player state: hp=%d dead=%v terminal=%v", p.Health, p.Dead, p.Terminal)
|
||||||
|
}
|
||||||
|
if got := len(e.logs); got != 6 {
|
||||||
|
t.Fatalf("terminal event was not ignored, logs=%d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseExpiresActiveAttempt(t *testing.T) {
|
||||||
|
e, err := NewEngine(Config{Floors: 2, Monsters: 1, OpenAt: "10:00:00", Duration: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.Apply(Event{At: mustClock("10:00:00"), Player: 1, ID: 1})
|
||||||
|
e.Apply(Event{At: mustClock("10:10:00"), Player: 1, ID: 2})
|
||||||
|
e.Apply(Event{At: mustClock("11:30:00"), Player: 1, ID: 3})
|
||||||
|
|
||||||
|
p := e.players[1]
|
||||||
|
if !p.Terminal || p.EndedAt != mustClock("11:00:00") {
|
||||||
|
t.Fatalf("expected close at 11:00:00, got terminal=%v ended=%s", p.Terminal, formatDuration(p.EndedAt))
|
||||||
|
}
|
||||||
|
if strings.Contains(e.Output(), "killed the monster") {
|
||||||
|
t.Fatal("event after close was processed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustClock(value string) time.Duration {
|
||||||
|
d, err := parseClock(value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
51
internal/challenge/models.go
Normal file
51
internal/challenge/models.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package challenge
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Floors int
|
||||||
|
Monsters int
|
||||||
|
OpenAt string
|
||||||
|
Duration int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
At time.Duration
|
||||||
|
Player int
|
||||||
|
ID int
|
||||||
|
Extra string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResultState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StateSuccess ResultState = "SUCCESS"
|
||||||
|
StateFail ResultState = "FAIL"
|
||||||
|
StateDisqual ResultState = "DISQUAL"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Player struct {
|
||||||
|
ID int
|
||||||
|
Health int
|
||||||
|
Registered bool
|
||||||
|
InDungeon bool
|
||||||
|
Attempted bool
|
||||||
|
Terminal bool
|
||||||
|
Disqual bool
|
||||||
|
Dead bool
|
||||||
|
Left bool
|
||||||
|
|
||||||
|
EnteredAt time.Duration
|
||||||
|
EndedAt time.Duration
|
||||||
|
|
||||||
|
Floor int
|
||||||
|
MonsterKills []int
|
||||||
|
FloorDurations []time.Duration
|
||||||
|
floorTimerOn bool
|
||||||
|
floorTimerAt time.Duration
|
||||||
|
|
||||||
|
BossEntered bool
|
||||||
|
BossKilled bool
|
||||||
|
BossEnterAt time.Duration
|
||||||
|
BossTime time.Duration
|
||||||
|
}
|
||||||
35
internal/challenge/runner.go
Normal file
35
internal/challenge/runner.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package challenge
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
func Run(configPath, eventsPath string) (string, error) {
|
||||||
|
cfgFile, err := os.Open(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer cfgFile.Close()
|
||||||
|
|
||||||
|
eventsFile, err := os.Open(eventsPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer eventsFile.Close()
|
||||||
|
|
||||||
|
cfg, err := readConfig(cfgFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
events, err := readEvents(eventsFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
engine, err := NewEngine(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, event := range events {
|
||||||
|
engine.Apply(event)
|
||||||
|
}
|
||||||
|
return engine.Output(), nil
|
||||||
|
}
|
||||||
21
internal/challenge/timers.go
Normal file
21
internal/challenge/timers.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package challenge
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
func (e *Engine) startFloorTimer(p *Player, at time.Duration) {
|
||||||
|
if p.Floor >= 1 && p.Floor <= e.regularFloors && !e.floorCleared(p, p.Floor) {
|
||||||
|
p.floorTimerOn = true
|
||||||
|
p.floorTimerAt = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) pauseFloorTimer(p *Player, at time.Duration) {
|
||||||
|
if p.floorTimerOn {
|
||||||
|
p.FloorDurations[p.Floor] += at - p.floorTimerAt
|
||||||
|
p.floorTimerOn = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) finishFloorTimer(p *Player, at time.Duration) {
|
||||||
|
e.pauseFloorTimer(p, at)
|
||||||
|
}
|
||||||
58
internal/challenge/timeutil.go
Normal file
58
internal/challenge/timeutil.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package challenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const day = 24 * time.Hour
|
||||||
|
|
||||||
|
func parseClock(value string) (time.Duration, error) {
|
||||||
|
parts := strings.Split(value, ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return 0, fmt.Errorf("bad time %q", value)
|
||||||
|
}
|
||||||
|
h, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
m, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
s, err := strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if h < 0 || h > 23 || m < 0 || m > 59 || s < 0 || s > 59 {
|
||||||
|
return 0, fmt.Errorf("bad time %q", value)
|
||||||
|
}
|
||||||
|
return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatClock(d time.Duration) string {
|
||||||
|
d %= day
|
||||||
|
if d < 0 {
|
||||||
|
d += day
|
||||||
|
}
|
||||||
|
h := int(d / time.Hour)
|
||||||
|
d -= time.Duration(h) * time.Hour
|
||||||
|
m := int(d / time.Minute)
|
||||||
|
d -= time.Duration(m) * time.Minute
|
||||||
|
s := int(d / time.Second)
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < 0 {
|
||||||
|
d = 0
|
||||||
|
}
|
||||||
|
h := int(d / time.Hour)
|
||||||
|
d -= time.Duration(h) * time.Hour
|
||||||
|
m := int(d / time.Minute)
|
||||||
|
d -= time.Duration(m) * time.Minute
|
||||||
|
s := int(d / time.Second)
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
||||||
|
}
|
||||||
24
main.go
Normal file
24
main.go
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"impulse/internal/challenge"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath, eventsPath := "config.json", "events"
|
||||||
|
if len(os.Args) == 3 {
|
||||||
|
configPath, eventsPath = os.Args[1], os.Args[2]
|
||||||
|
} else if len(os.Args) != 1 {
|
||||||
|
fmt.Fprintf(os.Stderr, "usage: %s [config.json events]\n", os.Args[0])
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := challenge.Run(configPath, eventsPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Print(out)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue