Trek.groovy

package net.ebdon.trk21;

import java.text.MessageFormat;
import org.codehaus.groovy.tools.groovydoc.ClasspathResourceManager;
import groovy.transform.TypeChecked;

import static GameSpace.*;
import static Quadrant.*;
import static ShipDevice.*;
/**
 * @file
 * @author      Terry Ebdon
 * @date        January 2019
 * @copyright   Terry Ebdon, 2019
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
@brief A Groovy version of the 1973 BASIC-PLUS program TREK.BAS
@author Terry Ebdon
@date JAN-2019
*/
@groovy.util.logging.Log4j2
final class Trek extends LoggingBase {
  def ui;
  // UiBase ui;
  PropertyResourceBundle rb;
  MessageFormat formatter;

  /// @todo Rename Trek.game, it's a misleading name for TrekCalendar
  TrekCalendar game = new TrekCalendar();

    static String logPositionPieces = 'Positioning game pieces {} in quadrant {}'

    Galaxy    galaxy    = new Galaxy();
    Quadrant  quadrant  = new Quadrant();

    Repositioner repositioner;
    DamageControl damageControl;

    EnemyFleet enemyFleet = new EnemyFleet();
    FederationShip ship;
    int numStarBasesTotal = 0; ///< b9% in TREK.BAS

  @TypeChecked
  int getEntQuadX() {
    ship.position.quadrant.row
  }

  @TypeChecked
  void setEntQuadX( final int newPos ) {
    ship.position.quadrant.row = newPos
  }

  @TypeChecked
  int getEntSectX() {
    ship.position.sector.row
  }

  @TypeChecked
  void setEntSectX( final int newPos ) {
    ship.position.sector.row = newPos
  }

  @TypeChecked
  int getEntQuadY() {
    ship.position.quadrant.col
  }

  @TypeChecked
  void setEntQuadY( final int newPos ) {
    ship.position.quadrant.col = newPos
  }

  @TypeChecked
  int getEntSectY() {
    ship.position.sector.col
  }

  @TypeChecked
  void setEntSectY( final int newPos ) {
    ship.position.sector.col = newPos
  }

  Map<Integer,ShipDevice> damage = [
    1: new ShipDevice('device.WARP.ENGINES'), 2: new ShipDevice('device.S.R..SENSORS'),
    3: new ShipDevice('device.L.R..SENSORS'), 4: new ShipDevice('device.PHASER.CNTRL'),
    5: new ShipDevice('device.PHOTON.TUBES'), 6: new ShipDevice('device.DAMAGE.CNTRL')
  ]; ///< D%[] and D$[] in TREK.BAS.
     ///< @todo Move damage[] into FederationShip.
     ///< @note elements [n][0] are keys to the Language resource bundle, via #rb.

  @TypeChecked
  boolean isValid() {
    ship?.valid && game?.valid && enemyFleet?.valid
  }

  String toString() {
    "Calendar   : $game\n" +
    "Ship       : $ship\n" +
    "EnemyFleet : $enemyFleet\n" +
    "Quadrant   : [$entQuadX, $entQuadY]\n" +
    "Sector     : [$entSectX, $entSectY]"
  }

  Trek( theUi = null ) {
    ui = theUi
    formatter = new MessageFormat("");
    formatter.setLocale( Locale.getDefault() );

    ClasspathResourceManager resourceManager = new ClasspathResourceManager()
    InputStream inStream = resourceManager.getInputStream('Language.properties')

    if ( inStream ) {
      rb = new PropertyResourceBundle( inStream )
      repositioner = new Repositioner( this )
      damageControl = new DamageControl( damage )
    } else {
        log.fatal "Can't load Language.poperties"
        assert inStream
    }
  }

  @groovy.transform.TypeChecked
  def setupGalaxy() {
    setEntStartPosition()
    distributeKlingons()
    dumpGalaxy()
    setupQuadrant()
  }

  def setEntStartPosition() {
    ship.position.quadrant = new Coords2d( *(galaxy.randomCoords) )
  }

  @groovy.transform.TypeChecked
  def setupQuadrant() {
    enemyFleet.numKlingonBatCrInQuad = 0
    quadrant.clear()
    positionShipInQuadrant()
    positionGamePieces()
  }

  void positionShipInQuadrant() {
    log.info "Position ship within quadrant ${ship.position.quadrant}"
    assert ship.position.quadrant.isValid()
    ship.position.sector = new Coords2d( *(quadrant.randomCoords) )
    quadrant[ship.position.sector] = Thing.ship
    log.debug "Ship positioned at sector ${ship.position.sector}"
    assert quadrant.isValid()
    quadrant.dump()
  }

  @groovy.transform.TypeChecked
  private updateNumEnemyShipsInQuad() {
    galaxy[entQuadX,entQuadY] -= 100 * numEnemyShipsInQuad()
    galaxy[entQuadX,entQuadY] += 100 * enemyFleet.numKlingonBatCrInQuad
  }

  @groovy.transform.TypeChecked
  private int numEnemyShipsInQuad() {
    ( galaxy[entQuadX,entQuadY] / 100 ).toInteger()
  }

  @groovy.transform.TypeChecked
  private int numBasesInQuad() {
    ( galaxy[entQuadX,entQuadY] / 10 - 10 * numEnemyShipsInQuad() ).toInteger()
  }

  @groovy.transform.TypeChecked
  private int numStarsInQuad() {
    galaxy[entQuadX,entQuadY] - numEnemyShipsInQuad() * 100 - numBasesInQuad() * 10
  }

  @groovy.transform.TypeChecked
  void positionGamePieces() {
    log.info( logPositionPieces,
        galaxy.scan(ship.position.quadrant), ship.position.quadrant)

    positionEnemy()
    positionBases()
    positionStars()
  }

  void positionEnemy() {
    log.info 'positionEnemy()'
    assert quadrant.isValid()
    enemyFleet.numKlingonBatCrInQuad = numEnemyShipsInQuad()
    enemyFleet.resetQuadrant()
    log.info "Positioning $enemyFleet.numKlingonBatCrInQuad Klingons in quadrant ${currentQuadrant()}."
    for ( int klingonShipNo = 1; klingonShipNo <= enemyFleet.numKlingonBatCrInQuad; ++klingonShipNo ) {
      def klingonPosition = quadrant.emptySector
      quadrant[klingonPosition] = Thing.enemy

      enemyFleet.positionInSector klingonShipNo, klingonPosition
    }

    log.info 'v'*16
    log.info "quad with Klingons"
    quadrant.displayOn( {log.info it} )
    log.info "quad with Klingons"
    log.info '^'*16
  }

  void positionStars() {
    log.info "Positioning ${numStarsInQuad()} stars."
    for ( int star = 1; star <= numStarsInQuad(); ++star ) {
      def starPos = quadrant.emptySector
      log.trace "... star $star is at sector ${starPos}"
      quadrant[starPos[0],starPos[1]] = Thing.star
      log.trace "V*: ${quadrant[starPos[0],starPos[1]]}"
    }
  }

  void positionBases() {
    log.info "Positioning ${numBasesInQuad()} bases."
    for ( int base = 1; base <= numBasesInQuad(); ++base ) {
      def basePos = quadrant.emptySector
      log.trace "... base $base is at sector ${basePos}"
      quadrant[basePos] = Thing.base
    }
  }

  int rand1to9() { // fnr%() -- Used a lot, as there are most 9 bases / Klingons / stars in each quadrant.
     new java.util.Random().nextFloat() * 8 + 1
  }

  void distributeKlingons() {
    int totalStars = 0
    int starsInQuad = 0
    // int numBasesInQuad = 0 //b3%

    log.info "Distributing Klingon battle cruisers."
    minCoord.upto(maxCoord) { i->
      minCoord.upto(maxCoord) { j->
        enemyFleet.numKlingonBatCrInQuad = 0 // k3%
        // int b9 = 0 //b9%
        def c1 = new java.util.Random().nextFloat()*64 //rnd * 64

        1.upto( enemyFleet.maxKlingonBCinQuad ) {
          if ( c1 < enemyFleet.softProbs[ it ] ) {
            ++enemyFleet.numKlingonBatCrTotal   // line 1200
            ++enemyFleet.numKlingonBatCrRemain  // line 1180
            ++enemyFleet.numKlingonBatCrInQuad  // line 1180
            log.trace "Enemy craft added to quadrant ${i} - ${j}, " +
                      "there's now ${enemyFleet.numKlingonBatCrInQuad} in this quadrant."
          }
        }

        final int numBasesInQuad = new java.util.Random().nextFloat() > 0.9 ? 1 : 0 // b3%
        numStarBasesTotal += numBasesInQuad // 1210 B9%=B9%+B3%
        starsInQuad = rand1to9()
        totalStars += starsInQuad
        galaxy[i,j] =
          enemyFleet.numKlingonBatCrInQuad * 100 +
          numBasesInQuad * 10 +
          starsInQuad //G%(I%,J%)=K3%*100%+B3%*10%+FNR%
        log.trace 'galaxy[{},{}] = {}', i, j, galaxy.scan(i,j)
      }
    }
    log.info "Total number of Klingon BC: ${enemyFleet.numKlingonBatCrRemain.toString().padLeft(3)}"
    log.info "Total number of star bases: ${numStarBasesTotal.toString().padLeft(3)}"
    log.info "Total number stars:         ${totalStars.toString().padLeft(3)}"
    log.info enemyFleet.toString()
  }

  @TypeChecked
  void dumpGalaxy() {
    galaxy.dump()
    reportEntPosition()
  }

  void reportEntPosition() {
    log.info "Enterprise is in quadrant ${currentQuadrant()}"
  }

  @TypeChecked
  String currentQuadrant() {
    "${entQuadY} - ${entQuadX}"
  }

  @TypeChecked
  void setupGame() {
    assert damage

    // try {
    logException {
      ship = new FederationShip();
      setupGalaxy()
      // shortRangeScan()
    }
    // } catch ( Exception ex ) {
    //   log.fatal 'Unexpected exception while setting up the game.'
    //   log.error ex.message
    //   throw ex
    // }
  }

  @TypeChecked
  void startGame() {
    shortRangeScan()
  }

  /// @deprecated Not needed with new font config.
  @TypeChecked
  String btnText( final String text ) {
    // "<html><font size=+3>$text</font></html>"
    text
  }

  @TypeChecked
  void localMsg( final String msgId ) {
    msgBox rb.getString( msgId )
  }

  @TypeChecked
  void localMsg( final String msgId, Object[] msgArgs ) {
    formatter.applyPattern( rb.getString( msgId ) )
    msgBox formatter.format( msgArgs )
  }

  void msgBox( msg, boolean logIt = true ) {
    ui.outln "$msg"
    if ( logIt ) {
      log.info msg
    }
  }

  /// @deprecated Switch the code to use the UI version.
  // Float getFloatInput( final String prompt ) {
  //   ui.getFloatInput( prompt )
  // }

  @TypeChecked
  void reportDamage() {
    damageControl.report( rb, this.&msgBox, formatter )
  }

  void damageRepair() {
    log.info "Repairing damage"
    damageControl.repair( this.&msgBox )
  }

  void attackReporter( damageAmount, message ) {
    log.info "Ship under attack, $damageAmount units of damage sustained."
    msgBox message
    //:E% -= damageAmount // Line 2410
  }

  @TypeChecked
  void klingonAttack() {
    if ( !ship.isProtectedByStarBase() ) {
      enemyFleet.attack( ship.position.sector, this.&attackReporter )
    } else {
      localMsg 'starbase.shields'
    }
  }

  @TypeChecked
  void spaceStorm() {
    final int systemToDamage      = new Random().nextInt( damage.size() ) + 1
    final int damageAmount        = new Random().nextInt(5) + 1
    damage[systemToDamage].state -= damageAmount
    log.info "Space storm has damaged device No. $systemToDamage"
    log.info "   damage of $damageAmount units"
    log.info "   new status: ${damage[systemToDamage].state} units"
    localMsg 'deviceStatusLottery.spaceStorm', [ damage[systemToDamage].name ]
  }

  @TypeChecked
  void deviceStatusLottery() {
    assert damage
    log.debug 'deviceStatusLottery()'
    if ( new Random().nextFloat() <= 0.5 ) { // 1760
      spaceStorm()
    } else { // 1790 - Not a space storm
      randomDeviceRepair()
    }
  }

  @TypeChecked
  void randomDeviceRepair() {
    final int firstDamagedDeviceKey = damageControl.findDamagedDeviceKey()
    if ( firstDamagedDeviceKey ) {
      damageControl.randomlyRepair( firstDamagedDeviceKey )
      final String damagedDeviceId = damage[firstDamagedDeviceKey].id

      localMsg 'truce', [ "device.DAMAGE.$damagedDeviceId" ]

      // Object[] msgArgs = [ "device.DAMAGE.$damagedDeviceId" ]
      // formatter.applyPattern( rb.getString( 'truce' ) );
      // msgBox formatter.format( msgArgs );
    }
  }

  @TypeChecked
  void enemyAttacksBeforeShipCanMove() {
    if ( enemyFleet.canAttack() ) {
      log.info 'Klingons attack before the ship can move away.'
      klingonAttack()
    }
  }

  @TypeChecked
  boolean tooFastForDamagedEngine( final ShipVector sv ) {
    log.trace "tooFastForDamagedEngine called with $sv"
    sv.warpFactor > 0.2F && damageControl.isDamaged( ShipDevice.DeviceType.engine )
  }

  /// @todo Localise setCourse()
  @TypeChecked
  void setCourse() {
    ShipVector vector = getShipCourse()
    if ( vector && vector.valid ) {
      log.info "Got a good vector: $vector"

      if ( tooFastForDamagedEngine( vector ) ) {
        localMsg 'engine.damaged'
        localMsg 'engine.damaged.max'
      } else {
        enemyAttacksBeforeShipCanMove()
        damageRepair() /// @todo Is damageRepair() called at the correct point?

        if ( new Random().nextFloat() <= 0.20 ) { // 1750
          deviceStatusLottery()
        }
        game.tick() // Line 1830
        final Coords2d oldQuadrant = ship.position.quadrant.clone()
        ship.move( vector ) // set course, start engines...

        if ( gameContinues() ) {
          log.trace "Ship has moved, but where is it?"
          // Continue from line 1840...

          repositioner.repositionShip vector
          repopulateSector oldQuadrant
          shortRangeScan()
        }
      }
    } else {
      log.info "vector is not so good: $vector"
      if (vector.course * vector.warpFactor != 0 ) { // User didn't hit cancel
        msgBox "That's not a valid course / warp factor. Command refused."
      }
    }
  }

  final private void repopulateSector( final oldQuadrant ) {
    log.info "Quadrant was $oldQuadrant"
    log.info "Quadrant now $ship.position.quadrant"
    if ( ship.position.quadrant != oldQuadrant ) {
      log.info "Ship jumped to new quadrant $ship.position.quadrant"
      log.info "Setup in new quadrant at $ship.position"
      setupQuadrant()
    } else {
      log.info "Ship didn't jump quadrants."
    }
  }

  /// @todo localise blockedAtSector( row, column )
  void blockedAtSector( row, column ) {
    msgBox "Ship blocked by ${quadrant[row,column]} at sector ${logFmtCoords( row, column )}"
  }

  String logFmtCoords( x, y ) {
    "${[x,y]} == $y - $x"
  }
  /// @todo Reverse the coordinates? i.e. i,j or j,i?
  /// @deprecated
  @TypeChecked
  boolean sectorIsOccupied( final int i, final int j ) {
    quadrant.isOccupied(i,j)
  }

  float getCourse() {
    ui.getFloatInput( rb.getString( 'input.course' ) ) // C1
  }

  /// @todo Test needed for getShipCourse()
  ShipVector getShipCourse() {
    float course        = 0
    float warpFactor    = 0
    ShipVector sv = new ShipVector()

    log.trace '''Getting ship's course'''

    course = getCourse()
    if ( ShipVector.isValidCourse( course ) ) {
      sv.course = course
      warpFactor = ui.getFloatInput( rb.getString( 'input.speed' ) ) // W1
      if ( ShipVector.isValidWarpFactor( warpFactor ) ) {
        sv.warpFactor = warpFactor
      } else {
        log.info "Warp factor $warpFactor is outside of expected range."
      }
    } else {
      log.info "Course value $course is outside of expected range."
    }
    sv
  }

  /// Perform a @ref TrekLongRangeSensors "long-range sensor scan"
  // @groovy.transform.TypeChecked
  void longRangeScan() {
    if ( damageControl.isDamaged( DeviceType.lrSensor ) ) {
      msgBox rb.getString( 'sensors.longRange.offline' )
    } else {
      Object[] msgArgs = [ currentQuadrant() ]
      formatter.applyPattern( rb.getString( 'sensors.longRange.scanQuadrant') );
      msgBox formatter.format( msgArgs );

      ( entQuadX - 1 ).upto( entQuadX + 1 ) { int i ->    // q1% -1 to q1% + 1
        // String lrStatusLine = ''
        // ( entQuadY - 1 ).upto( entQuadY + 1 ) { int j ->  // q2% -1 to q2% + 1
        //   lrStatusLine += ( '  ' + galaxy.scan( i, j ) )
        // }
        // msgBox lrStatusLine
        msgBox longRangeScanRow( i )
      }
    }
  }

  // @groovy.transform.TypeChecked
  private String longRangeScanRow( final int row ) {
    String rowStatus = ''
    ( entQuadY - 1 ).upto( entQuadY + 1 ) { int col ->  // q2% -1 to q2% + 1
      rowStatus += ( '  ' + galaxy.scan( row, col ) )
    }
    rowStatus
  }

  void showCondition() {
    ui.conditionText = displayableCondition()
  }

  /// @return A localised display string for the ship's condition
  /// @todo Move displayableCondition() into FederationShip
  /// @todo Localise via the #rb resource bundle.
  /// @bug  Code assumes that all condition values, other than "DOCKED",
  ///       are also valid HTML colours. This is currently true for ENGLISH
  ///       locales, but will fail for other languages. Colours and language
  ///       should be orthogonal.
  /// @todo Consider splitting into two methods, to allow use where HTML is
  ///       not appropriate.
  String displayableCondition() {
    /// @bug Should use config insteaf of HTML fonts.
    /// @todo This is incompatible with a CLI based UI.
    def colour = ship.condition != 'DOCKED' ? ship.condition : 'GREEN'
    "<html><font size=+2 color=$colour>${ship.condition}</font></html>"
  }

  /// Perform a @ref TrekShortRangeSensors "short-range sensor scan"
  void shortRangeScan() {
    logException {
      ship.shortRangeScan( galaxy )
      ship.attemptDocking( quadrant )
      // @todo Is this methode code-complete?
      // GOSUB 2370 UNLESS A%
      // 1570 IF D%(2%) THEN &"SHORT RANGE SENSORS ARE INOPERABLE":GOTO1650

      msgBox( '---------------', false )
      quadrant.displayOn( {msgBox it} )

      msgBox( '---------------', false )
      showCondition()
      msgBox( sprintf("%8s: %5d  %15s: %s", rb.getString('starDate'), game.currentSolarYear, rb.getString('condition'),ship.condition ) )
      msgBox( sprintf('%8s: %5s  %15s: %d - %d', rb.getString('quadrant'),currentQuadrant(), rb.getString('sector'),entSectY, entSectX ) )
      msgBox( sprintf('%8s: %5d  %15s: %2d', rb.getString('energy'),ship.energyNow, rb.getString('missiles'),ship.numTorpedoes ) )
      msgBox( sprintf('%8s: %5d', rb.getString('enemy'), enemyFleet.numKlingonBatCrRemain ) )

      log.info game.toString()
    }
  }

  @TypeChecked
  private void attackFleetWithPhasers( final int energy ) {
    new Battle(
      enemyFleet, ship, damageControl,
      this.&msgBox, this.&attackReporter, rb
    ).phaserAttackFleet( energy )
  }

  void updateQuadrantAfterSkirmish() {
    updateNumEnemyShipsInQuad()
    def enemiesAtStart = quadrant.findEnemies()
    log.info "Found ${enemiesAtStart ? enemiesAtStart.size() : 'no'} possibly dead enemies."
    enemiesAtStart.each {
      if ( !enemyFleet.isShipAt( it.key ) ) {
        log.info "Removing vanquished ${it.value} from sector ${it.key}"
        quadrant.removeEnemy( it.key )
      }
    }
  }

  final void fireTorpedo() {
    log.info "Fire torpedo - available: ${ship.numTorpedoes}"

    float course = getCourse()
    if ( course ) {
      new Battle(
        enemyFleet, ship, damageControl,
        this.&msgBox, this.&attackReporter, rb
      ).fireTorpedo( course )
    }
    log.info "Fire torpedo completed - available: ${ship.numTorpedoes}"
  }

  // @TypeChecked
  final void firePhasers() {
    log.info "Fire phasers - available energy: ${ship.energyNow}"

    final int energy = ui.getFloatInput( rb.getString( 'input.phaserEnergy' ) ).toInteger()
    if ( energy > 0 ) {
      if ( energy <= ship.energyNow ) {
        attackFleetWithPhasers energy
        updateQuadrantAfterSkirmish()
      } else {
        log.info 'Phaser command refused; user tried to fire too many units.'
        localMsg 'phaser.refused.badEnergy'
      }
    } else {
      log.info 'Command cancelled by user.'
    }
    log.info "Fire phasers completed - available energy: ${ship.energyNow}"
  }

  void victoryDance() {

    Object[] msgArgs = [
      game.currentSolarYear,
      enemyFleet.numKlingonBatCrTotal,
      game.elapsed(),
      rating()
    ]
    formatter.applyPattern( rb.getString( 'trek.victoryDance' ) );
    msgBox formatter.format( msgArgs );
  }

  private int rating() {
    enemyFleet.numKlingonBatCrTotal / game.elapsed() * 1000
  }

  void shipDestroyed() {
    Object[] msgArgs = [
      game.currentSolarYear,
      enemyFleet.numKlingonBatCrRemain,
      game.elapsed()
    ]
    formatter.applyPattern( rb.getString( 'trek.funeral' ) );
    msgBox formatter.format( msgArgs );
  }

  boolean gameContinues() {
    !gameWon() && !gameLost()
  }

  boolean gameWon() {
    enemyFleet.defeated
  }

  boolean gameLost() {
    ship.deadInSpace() || game.outOfTime()
  }
}